Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 1706e84829 Fix #429: Type on a path brackets, flip and Type on a Path Options that keep the type in place
Type on a path could only be repositioned by Type on a Path Options, whose menu item sent
`{start: 0}`, so OK threw the text to the start of its path; nothing showed or dragged its
brackets. Now, as in Illustrator:

- Type on a path flows between a start and an end bracket (`TextKind::OnPath { end }`, None:
  the end of the path or once round a closed one; `TextKind::path_span`). Flipping
  (`TextObject::flip_on_path`) reverses the path keeping a closed path's first anchor and swaps
  the brackets, so the type keeps its stretch of the path.
- Type on a Path Options gains Align to Path (ascender, descender, center, baseline) and
  Spacing (glyphs spaced as if set that far above the path: closed up round the outside of
  curves, opened up round the inside), laid out by the text engine.
- `type.pathOptions {start?, end?, flip?, effect?, alignToPath?, spacing?, ids?}` sets them in
  one undo step; with none of them it queries. The menu item opens the dialog with the selected
  type's Effect, Flip, Align to Path and Spacing (dropdowns for the choices) and leaves the
  brackets alone, so OK as opened changes nothing.
- The Selection and Direct Selection tools show selected type on a path's path and its start,
  centre and end brackets (`tools::pathtype`, with a bracket cursor): dragging the start or end
  bracket sets where the type runs, dragging the centre bracket slides it along the path and,
  across the path, flips it (Cmd/Ctrl held: it only slides). Each drag is one undo step.
- Arc-length measuring moved to `vectorcraft_geom::ArcPath` (layout, the tools and
  `text.createInPath` share it). SVG export outlines path type whose end bracket, alignment or
  spacing `<textPath>` can't express.
- docs/mcp.md documents the command and the drags; ROADMAP updated; dialog strings translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:50:25 +07:00
BFlat 696d9f619b Merge pull request #430 from bobtherobot/fix/appearance-opacity-popup
Appearance: the Opacity popup's blend list and slider work
2026-10-08 12:49:58 +07:00
BFlat 3966b0cce5 Merge pull request #426 from raytheengineer/stroke-weight-presets-per-unit
Stroke: per-unit weight presets so mm/cm/in dropdowns show clean numbers
2026-10-08 12:49:39 +07:00
bflatasticandClaude Opus 5.5 b86d19feed Appearance: the popover closes when its anchor isn't shown (review of #430)
The popover kept its open flag while its anchor was hidden (panel closed, selection changed), so
it came back open the next time the row was drawn. It now remembers the last frame it was open in
and counts as closed after a frame without it, as egui's remembered popups do. ROADMAP: the
Appearance panel's Opacity popup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:27:26 +07:00
bflatasticandClaude Opus 5.5 f155bc5514 Stroke: keep the pt ladder, fix the fallback units and per-unit decimals (review of #426)
- The pt ladder stays 0.25 … 10, 20, 30 … 100 pt (the dropdown's documented 20/30/40 pt steps).
- Feet, yards and meters returned the pt ladder converted as if it were in that unit (a 100 m
  stroke); they now get the pt ladder in points.
- weight_presets returns a fixed array (no allocation per frame); the pica ladder is stored in
  points; 0.325 in is 0.375 in (the eighths ladder).
- Fields keep three decimals in points, pixels and picas (595.276 pt) and show four in mm, cm,
  inches and larger units (8.2677 in, 0.0078 in).
- ROADMAP: the Stroke panel's per-unit presets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:27:06 +07:00
Michael GiesonandClaude Opus 5.5 e6914861f1 Appearance: the Opacity popup's blend list and slider work
egui remembers one open popup at a time, so opening the blend-mode
dropdown or the opacity slider inside the Opacity popup closed the popup,
and the dropdown or slider with it. A new widgets::popover keeps its own
open state and closes only on Escape, its anchor, or a click outside it
and the popups it opened. The Opacity popup and the Stroke popover (which
holds dropdowns too) use it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:27:06 +07:00
raytheengineerandClaude Opus 4.7 60439d88b9 Stroke: per-unit weight presets so mm/cm/in dropdowns show clean numbers
The Stroke panel weight dropdown converted one pt-based preset list into
whatever Units > Stroke was set to, which produced ugly rounded values like
0.088 mm, 0.265 mm, 10.583 mm from the pt ladder. Each unit now has its own
native ladder (observed from Illustrator's running dropdowns and typed in),
converted to pt once at use. Points, Millimeters, Centimeters, Inches,
Pixels and Picas each get Illustrator's actual values; the exotic Unit
variants (Feet & Inches, Meters, Yards, Feet) fall back to the pt ladder.

Picas round-trip to exact pt weights (1 pt, 12 pt, 60 pt …) but display as
decimal picas until Unit::number learns 0p1 / 1p3 notation; that is a
system-wide formatter change left for a separate PR.

Also bumps Unit::number precision from 3 to 4 decimals so Illustrator's
sub-eighth-inch strokes (0.0078, 0.0156, 0.0313, 0.0625 in) show their
authored values instead of rounding to 0.008 / 0.016 / 0.031 / 0.063.
Trailing zeros are still trimmed, so 1 pt stays "1".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-08 12:26:47 +07:00
BFlat 2366000e19 Merge pull request #434 from storytold/fix/issue-400
Fix #400: Scrub numeric fields by dragging their label
2026-10-08 12:25:22 +07:00
bflatasticandClaude Opus 5.5 b71d877a0a Fix #400: Scrub numeric fields by dragging their label
Dragging a numeric field's label left or right steps the field's value,
as Photoshop's scrubby labels do (beyond Illustrator): one unit every
2 points, ten with Shift, a tenth with Ctrl/Cmd (counts stay whole), in
the field's unit and precision. The panels apply each value as they
apply a typed one, so the art follows live and the caller's limits
hold (dragging back from a limit changes the value at once).

- scrub.rs: the label zone over the label noted just before a field
  (dim_label, label_row, widgets::field_label, the Character and
  Paragraph panels' icons, the Transform and Properties rotate/shear
  icons), the ↔ cursor on hover, a click on the label focuses the
  field. The text box itself never scrubs; a field without a label
  doesn't. num_field, plain_field, mixed_field (and the spin fields)
  and opt_field take part.
- Engine: undo groups (Session::begin_undo_group / end_undo_group). The
  edits made in a group, commands and committed live drags alike, are
  one undo step, journaled one by one; a cancelled group is undone and
  dropped from the journal. The app opens one while a label is dragged,
  so a scrub is one undo step whichever path the panel applies values
  by (app.run, session.execute, live_run), and Escape cancels it
  (shortcuts leave Escape to the drag, so it doesn't close a dialog or
  deselect).
- Preferences > General > Scrub Numeric Fields by Dragging
  (`scrubNumericFields`, on by default), translated in ja, es, zh-hans,
  zh-hant and pt-br.
- Tests: label drags with Shift/Ctrl, limits, counts and units, Escape,
  the preference, the cursor, click-to-focus, no scrub on the box or
  without a label, and in the app one undo step for a W drag.
- ROADMAP and docs/mcp.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:33:57 +07:00
BFlat 48cfb414ca Merge pull request #431 from storytold/fix/issue-423
Fix #423: Movable dialogs and Offset Path's Joins dropdown
2026-10-08 10:59:44 +07:00
bflatasticandClaude Opus 5.5 e04385e950 Fix #423: Movable dialogs and Offset Path's Joins dropdown
The modal dialogs were anchored to the window's centre, so none of them
could be moved. They now share one modal frame (dialogs/modal.rs): the
band from the window's top edge down to its heading moves it, as a title
bar would, with a grab cursor; it stays on screen and keeps where it was
moved (per dialog, for the session). The shared dialog frame, New
Document (and More Settings), Preferences, Keyboard Shortcuts,
Workspaces and Find Font all use it; About moves by its title bar.

The form dialogs drew every string field as a text box, so Offset
Path's Joins had to be typed. Their choice fields are now dropdowns
(Joins: Miter, Round, Bevel; Average's axis; Shear's and Reflect's
axis), the fields follow the dialog's order (Offset, Joins, Miter Limit;
Rows, Columns, Gutter) instead of alphabetical, and Offset Path has a
Preview that Cancel rolls back.

The axis labels are translated in a new "axis" catalog context: the
plain Horizontal and Vertical rows mean writing direction in Japanese.
ja, es, zh-hans and zh-hant get the axis rows (Horizontal, Vertical,
Both) and Joins, Miter, Bevel, Offset and Axis.

Headless tests drag the heading of four dialog windows (moves, a drag
below the heading doesn't, kept on screen, reopens where it was left),
open the Joins dropdown and choose Round (same result as the command
with round joins), and check the axis dropdowns and the preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:52:05 +07:00
BFlat 4c9d27b4f5 Merge pull request #428 from storytold/fix/issue-421
Fix #421: Document Color Mode can put RGB greys on the black plate
2026-10-08 10:38:20 +07:00
BFlat 45f0dcc397 Merge pull request #425 from storytold/fix/issue-419
Fix #419: PDF/.ai art past the layer scan's limit never shows a hidden layer
2026-10-08 10:38:02 +07:00
BFlat ad28cf4793 Merge pull request #424 from storytold/fix/issue-420
Fix #420: SVG import keeps clipped layers as layers with a clipping mask
2026-10-08 10:37:45 +07:00
bflatasticandClaude Opus 5.5 693e928c9c Fix #421: Document Color Mode can put RGB greys on the black plate
Switching to CMYK separates RGB greys through the CMYK profile into
four-colour greys and a rich black, as Illustrator does; only Gray
colours stay on the black plate. The command catalogue said "greys stay
greys", which read as if R = G = B colours went to K only.

- file.documentColorMode (and its alias) and document.open {colorMode}
  take grays: "profile" (default, unchanged) | "black". With "black",
  RGB greys with R = G = B convert to K only, K = their grey value (the
  inks Edit Colors > Convert to Grayscale before the switch gives);
  other colours convert as before. Swatches, tints, gradient stops and
  pattern tiles follow the same rule.
- Pattern tiles are now converted with the rest of the document (they
  kept RGB colours in a CMYK document before), as the command said.
- The command catalogue, docs/mcp.md and the ROADMAP say what happens to
  Gray colours and RGB greys.
- Tests: the profile path (four-colour greys, rich black, pattern tiles,
  Gray kept), grays: "black" over art, swatches, tints, gradients and
  patterns, document.open, and the issue's file over MCP through a PDF
  export and back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:30:32 +07:00
bflatasticandClaude Opus 5.5 ad3e326d1d Fix #419: PDF/.ai art past the layer scan's limit never shows a hidden layer
The import walks each page's content to learn which optional content
group each marked-content sequence belongs to, and stopped after 2,000,000
operators. Past that, art went to the page's "Page N" layer, visible and
printable, so a layer that was off (or didn't print) showed and printed;
an Illustrator drawing of about a million paths lost 6 of its 15 layers.

- The walk now reads up to 64 million operators a page (it reads about
  15 million a second, twice as fast as the interpreter draws them; the
  issue's 2.1-million-operator file imports in 3 s, a 10-million one in
  15 s). Marked-content tags are kept as 64-bit hashes rather than byte
  vectors, and at most 4 million a page, so the walk's memory stays
  bounded on hostile input.
- When the walk is cut short (or the interpreter and the walk otherwise
  part) and some group is off or doesn't print, art in marked content
  whose group can't be told goes to a hidden, non-printing "Page N
  (unsorted)" layer instead of the page's layer, and the warning says
  so (and that the page was too large to read, when it was). Art drawn
  past the limit inside a group already open stays in its group; art
  outside any group stays on the page's layer. Files whose groups all
  show and print keep the old fallback.

Tests (with the limit lowered): a hidden, non-printing group past the
limit lands on the hidden unsorted layer while the open group keeps its
art and the page's art stays shown; with every group shown, art goes to
the page's layer as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:30:07 +07:00
bflatasticandClaude Opus 5.5 55543da0cb Fix #420: SVG import keeps clipped layers as layers with a clipping mask
A top-level <g id> becomes a layer only when it was plain, so an Inkscape
layer (or our own export of a layer with a clipping mask) carrying a
clip-path came in as a clip group, and a file whose layers were all
clipped to one frame opened as one "Layer 1" of clip groups with the
layer names lost.

A top-level group that does nothing but clip its art (no mask, filter,
opacity or blend) is now a layer too: its clip path becomes the layer's
clipping mask (the clipping path first, as Make Clipping Mask in the
Layers panel leaves it), a clip path clipped in turn nesting a clip
group inside the layer as before. The clip building is shared with clip
groups. Top-level text that joins a clipping layer (before or after it)
isn't in its clip: the mask then becomes a clip group inside the layer
and the text sits beside it. A clip path with no shape leaves the layer
empty.

Tests: the issue's Inkscape layers (names kept, each a clipping layer,
drawn as resvg draws the SVG), a nested clip on a layer, the export ->
import round trip of clipping layers, and text joining clipping layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:29:38 +07:00
BFlat 8b2be144cc Merge pull request #427 from storytold/fix/direct-anchor-snap
Direct Selection: dragged anchors snap to points and smart guides
2026-10-08 10:26:00 +07:00
bflatasticandClaude Opus 5.5 d1536bc355 Direct Selection: dragged anchors snap to points and smart guides
Dragging direct-selected anchors never snapped: the anchor drag sent the raw
pointer delta. Now the anchor pressed on (or the pointer on a straight segment)
snaps and the other selected anchors follow by the same delta:

- Smart Guides: onto other anchors (including the anchors of its own path that
  stay put), onto path segments ("path", new segment targets gathered only for
  anchor drags, lines exactly on straight segments), else into line with
  anchors, edges, centres and artboards ("align"). The anchors that move, their
  segments and the reshaped paths' bounds are no targets, so an anchor can't
  snap to itself. Targets are gathered once when the drag begins, capped.
- Smart Guides off: Snap to Point pulls it onto anchors and ruler guides within
  its distance; Snap to Grid / Snap to Pixel apply as for drawn points.
- Shift keeps the 45-degree constraint unsnapped.

Group Selection and Direct Selection's whole-object moves now snap like the
Selection tool: its move snapping moved into a shared `MoveSnap`, and
`snap_with` into a reusable `PointSnap` that caches its targets per drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:25:07 +07:00
BFlat 2776e91dc6 Merge pull request #417 from imachus/pr/ja-line-start-bracket
Type: set an opening bracket flush at the start of a wrapped line
2026-10-08 10:17:20 +07:00
bflatasticandClaude Opus 5.5 89c9b82955 Type: clarify what mojikumi still lacks in the ROADMAP (review of #417)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:10:00 +07:00
imachusandClaude Opus 5.5 908d966579 Type: set an opening bracket flush at the start of a wrapped line
With Line-end Punctuation Half Width, an opening bracket starting a
wrapped line now loses the half-em space before it (JLREQ 3.1.5), so its
face lines up with the line's start and the line has that much more room.
A paragraph's first line keeps the bracket full width.

Also fixes upright glyphs in vertical type that lost their leading space
(consecutive opening brackets, and now the line start): they moved up the
column but also half an em across it, because the turning point moved
instead of the glyph. The tests now check the position across the line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:58:14 +07:00
BFlat ef0b61aca1 Merge pull request #422 from storytold/fix/issue-394b
Part of #394: Preferences for snapping, handles, rubber bands, pattern tiles, tints and type
2026-10-08 09:55:43 +07:00
87 changed files with 3728 additions and 831 deletions

No files matched your search

+13 -10
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394) and skeleton reads with `document.find` for large documents over MCP (#416); see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -44,6 +44,7 @@ since 2026-10-01.
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
### Where we're lacking (in priority order)
@@ -104,7 +105,9 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 45 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, the smart guide display options, performance, devices…).
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too. Not yet: Select All across threaded frames (one frame's text is selected).
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
@@ -124,21 +127,21 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies, the trash deletes), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
- **Drawing aids:** Smart Guides and snapping (moving, and resizing by the bounding-box handles: the dragged edges align with the other objects; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers (Shift snaps them to the ruler's ticks) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the art's edges, centres and anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), and Draw Normal / Behind / Inside modes.
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles: the dragged edges align with the other objects; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers (Shift snaps them to the ruler's ticks) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the art's edges, centres and anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it.
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text.
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline; Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline or the em box top/right, centre or bottom/left (ICF top and bottom not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text.
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a wrapped line flush with its start, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline; Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline or the em box top/right, centre or bottom/left (ICF top and bottom not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; Alt deletes their pieces; filled open paths count as closed), Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units, shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups; Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
@@ -147,7 +150,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs).
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export.
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers; strokes kept live, a fill and a stroke of one outline as one object; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
@@ -165,7 +168,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
@@ -201,7 +204,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 79% | 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); composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — hanging punctuation, line-start brackets, kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), 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 | 20–28 |
| 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), hanging punctuation, kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), 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 | 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 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
+5 -2
View File
@@ -78,7 +78,7 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
pub use text::{
AreaOptions, CharAlign, CharPosition, CharStyle, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathEffect,
AreaOptions, CharAlign, CharPosition, CharStyle, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect,
ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape,
};
pub use vectorcraft_color as color;
@@ -191,7 +191,10 @@ impl Unit {
}
/// [`Unit::format`] without the suffix (`12.5`), for narrow fields.
pub fn number(self, pt: f64) -> String {
let s = format!("{:.3}", self.from_pt(pt));
// Three decimals for the small units (`595.276 pt`), four for the large ones (`8.2677 in`,
// `35.2778 mm`, the `0.0078 in` stroke preset); trailing zeros are trimmed (`1 pt` is `1`).
let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas) { 3 } else { 4 };
let s = format!("{:.*}", decimals, self.from_pt(pt));
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
+5 -2
View File
@@ -60,6 +60,9 @@ fn readouts_show_two_decimals() {
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
assert_eq!(Unit::Millimeters.readout(72.0), "25.40 mm");
assert_eq!(Unit::Inches.readout(-36.0), "-0.50 in");
assert_eq!(Unit::Millimeters.number(1.0), "0.353");
assert_eq!(Unit::Points.number(-0.0001), "0");
assert_eq!(Unit::Millimeters.number(1.0), "0.3528");
// Four decimals for the large units, three for the small ones.
assert_eq!(Unit::Inches.number(0.5616), "0.0078");
assert_eq!((Unit::Inches.number(595.2756), Unit::Points.number(595.2756)), ("8.2677".into(), "595.276".into()));
assert_eq!(Unit::Points.number(-0.00001), "0");
}
+105 -2
View File
@@ -539,6 +539,36 @@ impl PathEffect {
}
}
/// Type on a Path Options › Align to Path: which height of the type runs along the path.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PathAlign {
/// The font's top edge: the type hangs below the path.
Ascender,
/// The font's bottom edge: the type stands above the path.
Descender,
/// Halfway between the ascender and the descender.
Center,
/// The baseline (the default).
#[default]
Baseline,
}
impl PathAlign {
pub const ALL: [PathAlign; 4] = [PathAlign::Ascender, PathAlign::Descender, PathAlign::Center, PathAlign::Baseline];
pub fn id(self) -> &'static str {
match self {
PathAlign::Ascender => "ascender",
PathAlign::Descender => "descender",
PathAlign::Center => "center",
PathAlign::Baseline => "baseline",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim()))
}
}
/// Area Type Options: rows and columns, gutters, inset and first baseline of area type.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -583,8 +613,48 @@ pub enum TextKind {
Point,
/// Area type flowed inside `frame` (document coordinates, untransformed by `xf`).
Area { frame: PathData },
/// Type on a path, starting at `start` (0..1 of the path length).
OnPath { path: PathData, start: f64 },
/// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the
/// path length; no `end`: the end of the path, or once round a closed path). See
/// [`TextKind::path_span`].
OnPath {
path: PathData,
start: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
end: Option<f64>,
},
}
impl TextKind {
/// Type on a path's span: from its start to its end bracket, as fractions of its path's length.
/// Round a closed path the end may be past 1 (the span runs on past the path's start), a full
/// turn without an end or with the end at the start. None for other type.
pub fn path_span(&self) -> Option<(f64, f64)> {
let TextKind::OnPath { path, start, end } = self else { return None };
let s = fraction(*start);
Some(if path.is_closed() {
let run = end.map_or(0.0, |e| (fraction(e) - s).rem_euclid(1.0));
(s, s + if run < 1e-9 { 1.0 } else { run })
} else {
(s, end.map_or(1.0, |e| fraction(e).max(s)))
})
}
}
/// `x` as a fraction of a path's length (0..1; 0 when not finite).
fn fraction(x: f64) -> f64 {
if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 }
}
/// `path` run the other way, each point as far from its new start as it was from its old end
/// (the subpaths in reverse order, a closed one keeping its first anchor first).
fn reverse_from_end(path: &mut PathData) {
path.subpaths.reverse();
for sp in &mut path.subpaths {
sp.reverse();
if sp.closed && !sp.anchors.is_empty() {
sp.anchors.rotate_right(1);
}
}
}
/// A text object. `runs` split into paragraphs at `\n`.
@@ -605,6 +675,14 @@ pub struct TextObject {
/// Type on a Path effect (type on a path only).
#[serde(default, rename = "pathEffect", skip_serializing_if = "crate::skip::is_default")]
pub path_effect: PathEffect,
/// Type on a Path Options › Align to Path (type on a path only).
#[serde(default, rename = "pathAlign", skip_serializing_if = "crate::skip::is_default")]
pub path_align: PathAlign,
/// Type on a Path Options › Spacing in points (type on a path only): glyphs are spaced as if
/// set this far above the path, which closes them up round the outside of a curve and opens
/// them up round the inside.
#[serde(default, rename = "pathSpacing", skip_serializing_if = "crate::skip::is_default")]
pub path_spacing: f64,
/// Wrap objects above this area type, resolved by the engine after each edit (text space).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub wrap: Vec<WrapShape>,
@@ -623,6 +701,8 @@ impl TextObject {
para: ParaStyle::default(),
area: AreaOptions::default(),
path_effect: PathEffect::default(),
path_align: PathAlign::default(),
path_spacing: 0.0,
wrap: Vec::new(),
cached_bounds: None,
}
@@ -652,6 +732,29 @@ impl TextObject {
pub fn transform(&mut self, a: Affine) {
self.xf = a * self.xf;
}
/// Type on a path's path in document space; None for other type.
pub fn type_path(&self) -> Option<PathData> {
match &self.kind {
TextKind::OnPath { path, .. } => Some(path.transformed(self.xf)),
_ => None,
}
}
/// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its
/// centre bracket dragged across the path): the path runs the other way and the brackets swap
/// ends, so the type keeps its stretch of the path. False, changing nothing, for other type.
pub fn flip_on_path(&mut self) -> bool {
let Some((s, e)) = self.kind.path_span() else { return false };
let TextKind::OnPath { path, start, end } = &mut self.kind else { return false };
reverse_from_end(path);
if path.is_closed() {
*start = (1.0 - e).rem_euclid(1.0);
*end = (e - s < 1.0 - 1e-9).then(|| (1.0 - s).rem_euclid(1.0));
} else {
*start = 1.0 - e;
*end = (s > 1e-9).then_some(1.0 - s);
}
true
}
/// Area type's frame (the type area) in document space; None for other type.
pub fn area_frame(&self) -> Option<PathData> {
match &self.kind {
+46 -9
View File
@@ -8,9 +8,11 @@
//! become active when assigned.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, ProofTarget};
use vectorcraft_color::recolor::Neutral;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::ColorMode;
use vectorcraft_render::proof;
@@ -55,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert Document Color Mode",
[],
None,
"same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?} → {changed} (an alias kept for older scripts; the command palette leaves it out)",
"same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?, grays?} → {changed} (an alias kept for older scripts; the command palette leaves it out)",
has_doc,
convert_mode
),
@@ -232,6 +234,28 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"rgb": rgb, "cmyk": cmyk}))
}
/// How Document Color Mode converts neutral RGB greys (R = G = B) to CMYK (the `grays` param).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Grays {
/// Through the CMYK profile like any colour: four-colour greys and a rich black.
#[default]
Profile,
/// On the black plate only: K is the grey's ink percentage (as Convert to Grayscale and then
/// to CMYK would give).
Black,
}
impl Grays {
/// The `grays` param of `cmd`: `"profile"` (default) or `"black"`.
pub(crate) fn param(cmd: &str, p: &Value) -> Result<Self> {
match str_param(p, "grays").map(str::to_ascii_lowercase).as_deref() {
None | Some("profile") => Ok(Grays::Profile),
Some("black") => Ok(Grays::Black),
Some(g) => Err(bad(cmd, format!("grays must be \"profile\" or \"black\", not `{g}`"))),
}
}
}
/// File → Document Color Mode (`file.documentColorMode` and its alias
/// `object.convertDocumentColorMode`): set the mode and, unless `convert: false`, convert every
/// colour through the colour settings.
@@ -243,6 +267,7 @@ pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
};
let intent = intent_param(p, C)?;
let grays = Grays::param(C, p)?;
// Choosing the mode the document already has (the checked menu item) is not an edit.
if s.doc()?.doc.color_mode == mode {
return Ok(json!({ "changed": 0 }));
@@ -250,18 +275,20 @@ pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
let convert = bool_or(p, "convert", true);
let mut changed = 0usize;
s.edit("Document Color Mode", |d, _| {
changed = set_color_mode(d, mode, convert, intent);
changed = set_color_mode(d, mode, convert, intent, grays);
Ok(())
})?;
Ok(json!({ "changed": changed }))
}
/// Put `d` in colour `mode` and, with `convert`, convert every colour of its art, symbols and
/// swatches through the colour settings (`intent`, default: theirs) → how many colours changed.
/// Greys stay greys (they print on the black plate in either mode); Lab colours (spot colour
/// Put `d` in colour `mode` and, with `convert`, convert every colour of its art, symbols,
/// pattern tiles and swatches through the colour settings (`intent`, default: theirs) → how many
/// colours of the art, symbols and patterns changed. Gray colours stay Gray (they print on the
/// black plate in either mode); RGB greys are colours like any other and separate through the
/// CMYK profile, unless `grays` puts them on the black plate. Lab colours (spot colour
/// definitions) are device independent and fit either mode. Document Color Mode and
/// `document.open {colorMode}` both convert this way.
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool, intent: Option<Intent>) -> usize {
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool, intent: Option<Intent>, grays: Grays) -> usize {
d.color_mode = mode;
if !convert {
return 0;
@@ -269,15 +296,25 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode,
let c = cms::active();
let intent = intent.unwrap_or(c.settings().intent);
let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb };
let conv = |col: &Color| if matches!(col, Color::Gray { .. } | Color::Lab { .. }) { *col } else { c.convert(col, model, intent) };
let k_only = model == Model::Cmyk && grays == Grays::Black;
let conv = |col: &Color| match col {
Color::Gray { .. } | Color::Lab { .. } => *col,
// Grey ink percentage, then K only (a Gray colour's CMYK).
Color::Rgb { .. } if k_only && Neutral::of(col).is_some() => c.convert(&c.convert(col, Model::Gray, intent), Model::Cmyk, intent),
_ => c.convert(col, model, intent),
};
let mut changed = 0usize;
proof::map_document_colors(d, &mut |col, _| {
let mut count = |col: &Color, _: proof::Link| {
let n = conv(col);
if n != *col {
changed += 1;
}
n
});
};
proof::map_document_colors(d, &mut count);
for a in d.patterns.iter_mut().flat_map(|p| &mut p.art) {
proof::map_node_colors(Arc::make_mut(a), &mut count);
}
for sw in d.swatches_iter_mut() {
match &mut sw.paint {
Paint::Solid { color, .. } => *color = conv(color),
+1 -1
View File
@@ -189,7 +189,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Color Mode",
["File", "Document Color Mode"],
None,
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols and swatches through the colour settings; swatch links kept; greys stay greys), intent?} → {changed}",
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols, pattern tiles and swatches through the colour settings; swatch links kept; Gray colours stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB greys separate through the CMYK profile like any colour, into four-colour greys and a rich black) | \"black\" (to CMYK, RGB greys with R = G = B go on the black plate only, K = their grey value)} → {changed}",
has_doc,
super::colormgmt::convert_mode
),
+1 -1
View File
@@ -178,7 +178,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
};
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None);
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
}
// Imports are named after the file; a native document keeps its own title (the tab shows the
// file name once it has a path).
+1 -1
View File
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
+7 -2
View File
@@ -6,6 +6,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::{ColorMode, Document};
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs};
use super::super::colormgmt::Grays;
use super::super::*;
use super::{err, source};
@@ -22,6 +23,8 @@ pub struct LoadOptions {
/// The colour mode the document opens in, its colours converted as Document Color Mode does
/// (default: the file's; a PDF painted mostly in CMYK opens in CMYK).
pub color_mode: Option<ColorMode>,
/// How that conversion separates RGB greys (`grays`).
pub grays: Grays,
/// What a PDF's text becomes.
pub text_as: TextAs,
/// A PDF's optional content groups become layers (else one layer per page, without the art
@@ -38,6 +41,7 @@ impl Default for LoadOptions {
crop: CropTo::default(),
password: None,
color_mode: None,
grays: Grays::default(),
text_as: TextAs::default(),
layers: true,
dxf: Default::default(),
@@ -47,7 +51,7 @@ impl Default for LoadOptions {
impl LoadOptions {
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`),
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`,
/// `textAs` (`text` | `outlines`), `layers` (true | false) and `dxf` (the DXF import options).
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
@@ -65,13 +69,14 @@ impl LoadOptions {
Some("cmyk") => Some(ColorMode::Cmyk),
Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))),
};
let grays = Grays::param(cmd, p)?;
let text_as = choice(cmd, "textAs", str_param(p, "textAs"))?;
let layers = match p.get("layers").filter(|v| !v.is_null()) {
None => true,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
};
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
Ok(Self { pages, crop, password, color_mode, text_as, layers, dxf })
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf })
}
/// Does the document read only part of the file, or read it differently (a page range, another
+1
View File
@@ -130,6 +130,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("scaleCorners", "General", "Options", "Scale Corners", bool),
p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool),
p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool),
p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool),
// Selection & Anchor Display
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool),
+6 -2
View File
@@ -453,10 +453,10 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
}
let text = str_param(p, "text").unwrap_or("").to_string();
let start = match point_param(p, "at") {
Some(at) if on_path => vectorcraft_text::path_fraction_at(&path.to_bezpath(), at).0,
Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0),
_ => 0.0,
};
let kind = if on_path { TextKind::OnPath { path: path.clone(), start } } else { TextKind::Area { frame: path.clone() } };
let kind = if on_path { TextKind::OnPath { path: path.clone(), start, end: None } } else { TextKind::Area { frame: path.clone() } };
let mut t = TextObject {
vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false),
kind,
@@ -465,6 +465,8 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
para: super::create::new_type_para(),
area: Default::default(),
path_effect: Default::default(),
path_align: Default::default(),
path_spacing: 0.0,
wrap: Vec::new(),
cached_bounds: None,
};
@@ -595,6 +597,8 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
para: Default::default(),
area: Default::default(),
path_effect: Default::default(),
path_align: Default::default(),
path_spacing: 0.0,
wrap: Vec::new(),
cached_bounds: None,
};
+71 -14
View File
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Type on a Path Options…",
["Type", "Type on a Path"],
None,
"{start?: 0..1 (fraction of the path length), flip?: bool (reverse the path), effect?: rainbow|skew|3dRibbon|stairStep|gravity, ids?}",
"{ids?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
has_selection,
path_options
),
@@ -343,38 +343,95 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
Ok(ids_json(&ids))
}
/// The options `type.pathOptions` sets; with none of them given it queries.
const PATH_OPTIONS: [&str; 6] = ["start", "end", "flip", "effect", "alignToPath", "spacing"];
/// Type on a path's text, if `n` is type on a path.
fn path_text(n: Option<&vectorcraft_doc::Node>) -> Option<&TextObject> {
match n.map(|n| &n.kind) {
Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::OnPath { .. }) => Some(t),
_ => None,
}
}
/// Type on a Path Options of `t`, as `type.pathOptions` takes them (`flip` is an action: false).
fn path_options_of(t: &TextObject) -> Value {
let (start, end) = match &t.kind {
TextKind::OnPath { start, end, .. } => (*start, *end),
_ => (0.0, None),
};
json!({
"start": start,
"end": end,
"flip": false,
"effect": t.path_effect.id(),
"alignToPath": t.path_align.id(),
"spacing": t.path_spacing,
})
}
fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.pathOptions";
let ids = texts(s, p, C)?;
let start = p.get("start").and_then(Value::as_f64);
let ids: Vec<NodeId> = {
let d = &s.doc()?.doc;
texts(s, p, C)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect()
};
let first = *ids.first().ok_or_else(|| bad(C, "select type on a path"))?;
let options = |s: &Session| -> Result<Value> {
let t = path_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select type on a path"))?;
Ok(path_options_of(t))
};
if !PATH_OPTIONS.iter().any(|k| p.get(k).is_some()) {
return options(s);
}
// A bracket: a fraction of the path's length.
let fraction = |k: &str| -> Result<Option<f64>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| Some(x.clamp(0.0, 1.0))).ok_or_else(|| bad(C, format!("`{k}` is a number 0..1"))),
}
};
let start = fraction("start")?;
// `end: null` puts the end bracket back at the end of the path.
let end = p.get("end").map(|_| fraction("end")).transpose()?;
let flip = bool_or(p, "flip", false);
let effect = match str_param(p, "effect") {
Some(e) => Some(vectorcraft_doc::PathEffect::parse(e).ok_or_else(|| bad(C, format!("unknown effect `{e}`")))?),
None => None,
};
let n = s.edit("Type on a Path Options", |d, _| {
let mut n = 0;
let align = match str_param(p, "alignToPath") {
Some(a) => Some(vectorcraft_doc::PathAlign::parse(a).ok_or_else(|| bad(C, format!("unknown alignToPath `{a}`")))?),
None => None,
};
let spacing = p.get("spacing").and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(-1000.0, 1000.0));
s.edit("Type on a Path Options", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let TextKind::OnPath { path, start: st } = &mut t.kind else { continue };
let TextKind::OnPath { start: st, end: en, .. } = &mut t.kind else { continue };
if let Some(v) = start {
*st = v.clamp(0.0, 1.0);
*st = v;
}
if let Some(v) = end {
*en = v;
}
// After the brackets: they are given on the path as it runs before the flip.
if flip {
path.reverse();
t.flip_on_path();
}
if let Some(e) = effect {
t.path_effect = e;
}
n += 1;
if let Some(a) = align {
t.path_align = a;
}
if let Some(v) = spacing {
t.path_spacing = v;
}
refresh_bounds(t);
}
if n == 0 {
return Err(bad(C, "select type on a path"));
}
Ok(n)
Ok(())
})?;
Ok(json!({ "changed": n }))
options(s)
}
// ---------- placeholder / insert ----------
+73 -7
View File
@@ -89,6 +89,15 @@ pub struct Interaction {
pub perspective_again: Option<Value>,
}
/// An open undo group ([`Session::begin_undo_group`]): the edits made in it are one undo step.
#[derive(Clone, Debug)]
pub struct UndoGroup {
/// The document before the group's first edit, as the undo step that edit recorded keeps it.
first: Option<Arc<Document>>,
/// The journal's length when the group began: a cancelled group drops the entries after it.
journal: usize,
}
/// Per-document editing state.
#[derive(Clone, Debug)]
pub struct DocState {
@@ -112,6 +121,8 @@ pub struct DocState {
/// Isolation mode container.
pub isolation: Option<NodeId>,
pub interaction: Option<Interaction>,
/// Edits made while this is open are one undo step (a scrubbed numeric field).
pub undo_group: Option<UndoGroup>,
/// For Object → Transform → Transform Again (⌘D).
pub last_transform: Option<(Affine, bool)>,
/// Selection saved by Select → Reselect.
@@ -166,6 +177,7 @@ impl DocState {
layer_rows: vec![],
isolation: None,
interaction: None,
undo_group: None,
last_transform: None,
last_selection_cmd: None,
uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
@@ -197,11 +209,26 @@ impl DocState {
/// Keep what interaction `it` (taken from this document) changed, as one undo step.
pub(crate) fn keep_interaction(&mut self, it: Interaction) {
if !Arc::ptr_eq(&it.doc, &self.doc) {
self.history.undo.push(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection });
self.history.redo.clear();
self.push_undo(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection });
self.revision += 1;
}
}
/// Record undo step `e` (the document before an edit). In an undo group only the group's first
/// edit records one: the edits after it extend that step.
fn push_undo(&mut self, e: HistoryEntry) {
if let Some(g) = &mut self.undo_group {
let recorded = |first: &Arc<Document>| self.history.undo.last().is_some_and(|l| Arc::ptr_eq(&l.doc, first));
if g.first.as_ref().is_some_and(recorded) {
return;
}
g.first = Some(e.doc.clone());
}
self.history.undo.push(e);
if self.history.undo.len() > self.history.limit {
self.history.undo.remove(0);
}
self.history.redo.clear();
}
/// End the interaction in progress, undoing what it changed.
pub(crate) fn undo_interaction(&mut self) {
if let Some(it) = self.interaction.take() {
@@ -332,6 +359,8 @@ pub struct Prefs {
/// Scale Strokes & Effects.
pub scale_strokes: bool,
pub zoom_with_mouse_wheel: bool,
/// A horizontal drag on a numeric field or its label steps its value (#400).
pub scrub_numeric_fields: bool,
/// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this).
pub paste_offset: f64,
// Selection & Anchor Display
@@ -552,6 +581,7 @@ impl Default for Prefs {
scale_corners: false,
scale_strokes: false,
zoom_with_mouse_wheel: false,
scrub_numeric_fields: true,
paste_offset: 10.0,
selection_tolerance: 3.0,
object_selection_by_path_only: false,
@@ -1066,11 +1096,7 @@ impl Session {
st.selection.prune(&st.doc);
st.revision += 1;
if st.interaction.is_none() {
st.history.undo.push(HistoryEntry { label: label.to_string(), doc: before, selection: before_sel });
if st.history.undo.len() > st.history.limit {
st.history.undo.remove(0);
}
st.history.redo.clear();
st.push_undo(HistoryEntry { label: label.to_string(), doc: before, selection: before_sel });
}
Ok(v)
}
@@ -1184,6 +1210,44 @@ impl Session {
self.active().is_some_and(|d| d.interaction.is_some())
}
// ---------- undo groups (scrubbed numeric fields) ----------
/// Open an undo group in the active document: until [`Session::end_undo_group`], the edits
/// made there (commands, committed interactions) are one undo step. A scrubbed numeric field
/// applies each value it passes as its own command, as a typed value is applied; the drag is
/// one step.
pub fn begin_undo_group(&mut self) {
let journal = self.journal.len();
if let Some(st) = self.active_mut()
&& st.undo_group.is_none()
{
st.undo_group = Some(UndoGroup { first: None, journal });
}
}
/// Close the open undo groups: their edits stay one undo step or, `cancel`led (Escape), are
/// undone and dropped from the journal.
pub fn end_undo_group(&mut self, cancel: bool) {
let mut journal = None;
for st in &mut self.docs {
let Some(g) = st.undo_group.take() else { continue };
// Only while the group's step is still the newest one.
if cancel
&& let Some(first) = g.first
&& st.history.undo.last().is_some_and(|e| Arc::ptr_eq(&e.doc, &first))
&& let Some(e) = st.history.undo.pop()
{
st.doc = e.doc;
st.selection = e.selection;
st.revision += 1;
journal = Some(g.journal);
}
}
if let Some(len) = journal {
self.journal.truncate(len);
}
}
/// Commands with enablement (for menus, palette, MCP `list_commands`).
pub fn commands(&self) -> Vec<CommandInfo> {
command_specs().iter().map(|c| c.info(self)).collect()
@@ -1335,6 +1399,8 @@ mod tests_panelcmds;
#[cfg(test)]
mod tests_pathops;
#[cfg(test)]
mod tests_pathtype;
#[cfg(test)]
mod tests_pattern;
#[cfg(test)]
mod tests_pdffidelity;
+47
View File
@@ -249,3 +249,50 @@ fn a_failed_batch_gives_back_the_highlighted_rows_and_the_current_layer() {
let r = replayed(&s);
assert_eq!(documents(&r), documents(&s));
}
/// The edits made in an undo group (a scrubbed numeric field, #400) are one undo step, journaled
/// one by one; a cancelled group undoes them and leaves the journal as it was.
#[test]
fn an_undo_group_is_one_undo_step() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
let width = |s: &Session| s.transform_box(&s.doc().unwrap().selection.objects).unwrap().rect.width();
let undo_len = |s: &Session| s.doc().unwrap().history.undo.len();
let (undo, journal) = (undo_len(&s), s.journal.len());
s.begin_undo_group();
for w in [101, 102, 103] {
s.execute("object.setBounds", &json!({"width": w})).unwrap();
}
// A live drag committed in the group (the Transparency panel's opacity field) joins it too.
s.begin_interaction("Opacity").unwrap();
s.preview("transparency.set", &json!({"opacity": 50})).unwrap();
s.commit_interaction().unwrap();
s.end_undo_group(false);
assert!((width(&s) - 103.0).abs() < 1e-9);
assert_eq!(undo_len(&s), undo + 1, "one undo step");
assert_eq!(s.journal.len(), journal + 4, "each edit journaled");
s.execute("edit.undo", &json!({})).unwrap();
assert!((width(&s) - 100.0).abs() < 1e-9, "undo takes the whole drag back");
assert_eq!(undo_len(&s), undo);
s.execute("edit.redo", &json!({})).unwrap();
assert!((width(&s) - 103.0).abs() < 1e-9);
// Escape: back where it started, with no trace in the history or the journal.
let (undo, journal) = (undo_len(&s), s.journal.len());
s.begin_undo_group();
s.execute("object.setBounds", &json!({"width": 150})).unwrap();
s.execute("object.setBounds", &json!({"width": 160})).unwrap();
s.end_undo_group(true);
assert!((width(&s) - 103.0).abs() < 1e-9);
assert_eq!((undo_len(&s), s.journal.len()), (undo, journal));
// Closed: the next edits are steps of their own again.
s.execute("object.setBounds", &json!({"width": 110})).unwrap();
s.execute("object.setBounds", &json!({"width": 120})).unwrap();
assert_eq!(undo_len(&s), undo + 2);
// A group with no edits records nothing, and cancelling it undoes nothing.
s.begin_undo_group();
s.end_undo_group(true);
assert!((width(&s) - 120.0).abs() < 1e-9);
assert_eq!(undo_len(&s), undo + 2);
}
+117 -1
View File
@@ -1,7 +1,7 @@
//! Menu long-tail commands (Object/Edit/Select/Type/View/File), driven through `Session::execute`.
use serde_json::{Value, json};
use vectorcraft_color::Color;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ColorMode, LiveShape, Node, NodeKind, TextKind};
use vectorcraft_geom::Rect;
@@ -859,6 +859,122 @@ fn document_color_mode_converts_colours() {
assert!(s.execute("file.documentColorMode", &json!({"mode": "lab"})).is_err());
}
/// Four RGB greys (#000000, #333333, #808080, #e6e6e6, as in issue #421) and a red, a 40% tint of a
/// grey global swatch, a gradient to grey and a pattern of a grey tile → (session, the five
/// rectangles, the tinted one, the gradient one).
fn greys_document() -> (Session, Vec<NodeId>, NodeId, NodeId) {
let mut s = session();
let ids: Vec<NodeId> = ["#000000", "#333333", "#808080", "#e6e6e6", "#ff0000"]
.iter()
.map(|hex| {
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
fill(&mut s, id, hex);
id
})
.collect();
s.execute("swatch.new", &json!({"name": "Ink", "color": "#808080", "global": true})).unwrap();
let tint = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("paint.setFill", &json!({"ids": [tint.0], "swatch": "Ink", "tint": 40})).unwrap();
let grad = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#333333"}]);
s.execute("paint.setFill", &json!({"ids": [grad.0], "gradient": {"kind": "linear", "stops": stops}})).unwrap();
let tile = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
fill(&mut s, tile, "#808080");
s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Grey Tile", "edit": false})).unwrap();
(s, ids, tint, grad)
}
/// The colour of pattern `name`'s first tile object.
fn pattern_fill(s: &Session, name: &str) -> Color {
s.doc().unwrap().doc.pattern(name).unwrap().art[0].appearance.fill_paint().color().unwrap()
}
/// K of a colour that prints on the black plate only.
fn k_only(c: Color) -> f32 {
match c {
Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k } => k,
other => panic!("not K only: {other:?}"),
}
}
#[test]
fn document_color_mode_separates_rgb_greys_through_the_profile() {
let (mut s, ids, _, _) = greys_document();
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
// As Illustrator converts them: four-colour greys and a rich black.
let Color::Cmyk { c, m, y, k } = fill_of(&s, ids[2]) else { panic!("not CMYK") };
assert!(c > 0.05 && m > 0.05 && y > 0.05 && k > 0.0, "a four-colour mid grey: {c} {m} {y} {k}");
let Color::Cmyk { c, k, .. } = fill_of(&s, ids[0]) else { panic!("not CMYK") };
assert!(k > 0.9 && c > 0.3, "rich black: {c} {k}");
// Pattern tiles are swatches too: their art converts with the rest.
assert!(matches!(pattern_fill(&s, "Grey Tile"), Color::Cmyk { .. }), "{:?}", pattern_fill(&s, "Grey Tile"));
// Gray colours stay Gray.
let mut g = session();
let a = rect(&mut g, 0.0, 0.0, 10.0, 10.0);
g.execute("paint.setFill", &json!({"ids": [a.0], "color": {"gray": 0.3}})).unwrap();
g.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
assert_eq!(fill_of(&g, a), Color::gray(0.3));
}
#[test]
fn document_color_mode_can_put_rgb_greys_on_the_black_plate() {
let (mut profile, pids, ..) = greys_document();
profile.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
let (mut s, ids, tint, grad) = greys_document();
let e = s.execute("file.documentColorMode", &json!({"mode": "cmyk", "grays": "rich"})).unwrap_err().to_string();
assert!(e.contains("grays"), "{e}");
s.execute("file.documentColorMode", &json!({"mode": "cmyk", "grays": "black"})).unwrap();
let close = |a: f32, b: f32| (a - b).abs() < 0.005;
for (id, want) in ids.iter().zip([1.0, 0.8, 0.498, 0.098]) {
let k = k_only(fill_of(&s, *id));
assert!(close(k, want), "K {k}, want {want}");
}
// Other colours convert through the profile as before.
assert_eq!(fill_of(&s, ids[4]), fill_of(&profile, pids[4]));
// Swatches, their tints, gradient stops and pattern tiles follow the same rule.
let d = &s.doc().unwrap().doc;
assert!(close(k_only(d.swatch("Ink").unwrap().paint.color().unwrap()), 0.498));
let Paint::Solid { color, swatch, tint: t } = node(&s, tint).appearance.fill_paint() else { panic!("not solid") };
assert!(close(k_only(color), 0.498 * 0.4) && swatch.as_deref() == Some("Ink") && t == 0.4, "{color:?} {swatch:?} {t}");
let Paint::Gradient(g) = node(&s, grad).appearance.fill_paint() else { panic!("not a gradient") };
let ks: Vec<f32> = g.gradient.stops.iter().map(|st| k_only(st.color)).collect();
assert!(close(ks[0], 0.0) && close(ks[1], 0.8), "{ks:?}");
assert!(close(k_only(pattern_fill(&s, "Grey Tile")), 0.498));
// To RGB, `grays` changes nothing.
s.execute("file.documentColorMode", &json!({"mode": "rgb", "grays": "black"})).unwrap();
let rgb = s.doc().unwrap().doc.layers.clone();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("file.documentColorMode", &json!({"mode": "rgb"})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers, rgb);
}
#[test]
fn opening_in_cmyk_can_put_rgb_greys_on_the_black_plate() {
let mut s = session();
for hex in ["#000000", "#333333", "#808080", "#e6e6e6", "#ff0000"] {
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
fill(&mut s, id, hex);
}
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
let data = vectorcraft_format::base64_encode(svg.as_bytes());
let mut o = Session::new();
o.execute("document.open", &json!({"name": "greys.svg", "dataBase64": data, "colorMode": "cmyk", "grays": "black"})).unwrap();
let d = &o.doc().unwrap().doc;
assert_eq!(d.color_mode, ColorMode::Cmyk);
let mut fills = vec![];
for l in &d.layers {
l.walk(&mut |n| fills.extend(n.children().is_none().then(|| n.appearance.fill_paint().color()).flatten()));
}
assert_eq!(fills.len(), 5, "{fills:?}");
for (c, want) in fills.iter().zip([1.0, 0.8, 0.498, 0.098]) {
let k = k_only(*c);
assert!((k - want).abs() < 0.005, "K {k}, want {want}");
}
assert!(matches!(fills[4], Color::Cmyk { m, y, .. } if m > 0.8 && y > 0.8), "red separates as usual: {:?}", fills[4]);
let e = Session::new().execute("document.open", &json!({"name": "greys.svg", "dataBase64": data, "grays": "k"})).unwrap_err().to_string();
assert!(e.contains("grays"), "{e}");
}
#[test]
fn file_info_sets_title() {
let mut s = session();
+219
View File
@@ -0,0 +1,219 @@
//! Type on a path (#429): Type on a Path Options keep its brackets, flip it, align it to the path
//! and space it; the selection tools drag its brackets to move it along the path and across it.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeKind, PathAlign, TextKind, TextObject};
use vectorcraft_geom::{Point, Rect, Shape};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
/// 12 pt type on a path along `d` (SVG path data), starting where the path is nearest `at`.
fn path_type(s: &mut Session, d: &str, at: [f64; 2], text: &str) -> NodeId {
let pid = s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap();
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": text, "size": 12, "at": at})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
id
}
/// Type on the 400 pt line from (100, 300) to (500, 300), from its middle on.
fn on_line(s: &mut Session) -> NodeId {
path_type(s, "M100 300 L500 300", [300.0, 310.0], "Slide")
}
fn text(s: &Session, id: NodeId) -> TextObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
k => panic!("not text: {k:?}"),
}
}
/// The type's start and end brackets as stored.
fn brackets(s: &Session, id: NodeId) -> (f64, Option<f64>) {
match text(s, id).kind {
TextKind::OnPath { start, end, .. } => (start, end),
k => panic!("not type on a path: {k:?}"),
}
}
/// The glyphs' ink bounds in the document.
fn ink(s: &Session, id: NodeId) -> Rect {
let t = text(s, id);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
lay.glyphs
.iter()
.filter(|g| !g.outline.elements().is_empty())
.map(|g| t.xf.transform_rect_bbox(g.outline.bounding_box()))
.reduce(|a, b| a.union(b))
.unwrap()
}
/// The glyph origins along the path, in reading order.
fn origins(s: &Session, id: NodeId) -> Vec<Point> {
let t = text(s, id);
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).glyphs.iter().map(|g| t.xf * g.origin).collect()
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn last_step(s: &Session) -> String {
s.doc().unwrap().history.undo.last().unwrap().label.clone()
}
/// Press, drag and release `tool` from `from` to `to`.
fn drag(s: &mut Session, tool: &str, from: Point, to: Point, mods: Mods) {
let v = ViewInfo::default();
s.select_tool(tool, v).unwrap();
for (kind, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(mods), v).unwrap();
}
assert!(!s.in_interaction());
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-3
}
#[test]
fn options_keep_the_brackets() {
let mut s = session();
let id = on_line(&mut s);
assert!(near(brackets(&s, id).0, 0.5));
// The query (what the dialog shows) has the current options.
let mut q = s.execute("type.pathOptions", &json!({})).unwrap();
assert!(near(q["start"].as_f64().unwrap(), 0.5), "{q}");
q.as_object_mut().unwrap().remove("start");
assert_eq!(q, json!({"end": null, "flip": false, "effect": "rainbow", "alignToPath": "baseline", "spacing": 0.0}));
let start = brackets(&s, id).0;
// OK with the dialog's values (effect, flip, align, spacing) leaves the type where it is.
let before = origins(&s, id);
s.execute("type.pathOptions", &json!({"effect": "rainbow", "flip": false, "alignToPath": "baseline", "spacing": 0})).unwrap();
assert_eq!(brackets(&s, id), (start, None));
assert_eq!(origins(&s, id), before);
// So does a new effect.
s.execute("type.pathOptions", &json!({"effect": "skew"})).unwrap();
assert_eq!(brackets(&s, id), (start, None));
assert_eq!(text(&s, id).path_effect, vectorcraft_doc::PathEffect::Skew);
// The end bracket is set, and put back at the end of the path.
s.execute("type.pathOptions", &json!({"end": 0.9})).unwrap();
assert_eq!(brackets(&s, id), (start, Some(0.9)));
s.execute("type.pathOptions", &json!({"end": null})).unwrap();
assert_eq!(brackets(&s, id), (start, None));
for bad in [json!({"start": "x"}), json!({"effect": "nope"}), json!({"alignToPath": "top"})] {
assert!(s.execute("type.pathOptions", &bad).is_err(), "{bad}");
}
}
#[test]
fn flip_turns_the_type_to_the_other_side_of_its_stretch() {
let mut s = session();
let id = on_line(&mut s);
let up = ink(&s, id);
assert!(up.y1 < 301.0 && up.x0 > 299.0, "above the path from its middle: {up:?}");
let n = undo_len(&s);
let r = s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// The path runs the other way and the brackets swap ends: the type keeps the right half.
assert!(near(r["start"].as_f64().unwrap(), 0.0) && near(r["end"].as_f64().unwrap(), 0.5), "{r}");
let down = ink(&s, id);
assert!(down.y0 > 299.0 && down.x0 > 299.0, "below the path, on the same half: {down:?}");
let o = origins(&s, id);
assert!(o.first().unwrap().x > o.last().unwrap().x, "reading right to left: {o:?}");
// Flipped back, it is as it was.
s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
let (start, end) = brackets(&s, id);
assert!(near(start, 0.5) && end.is_none(), "{start} {end:?}");
assert!(ink(&s, id).y1 < 301.0);
}
#[test]
fn a_flip_round_a_closed_path_keeps_the_type_in_place() {
let mut s = session();
let id = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [200.0, 90.0], "Ring");
let (start, _) = brackets(&s, id);
assert!(near(start, 0.125), "{start}");
let first = origins(&s, id)[0];
s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
// Once round from the same point, the other way.
let (start, end) = brackets(&s, id);
assert!(near(start, 0.875) && end.is_none(), "{start} {end:?}");
assert!(origins(&s, id)[0].distance(first) < 8.0);
}
#[test]
fn align_to_path_and_spacing() {
let mut s = session();
let id = on_line(&mut s);
s.execute("type.pathOptions", &json!({"alignToPath": "ascender"})).unwrap();
assert_eq!(text(&s, id).path_align, PathAlign::Ascender);
let hang = ink(&s, id);
assert!(hang.y0 > 299.0, "hangs below the path: {hang:?}");
s.execute("type.pathOptions", &json!({"alignToPath": "center"})).unwrap();
let mid = ink(&s, id);
assert!(mid.y0 < 300.0 && mid.y1 > 300.0, "the path runs through it: {mid:?}");
// Spacing closes glyphs up round the outside of a curve.
let id = path_type(&mut s, "M100 400 A60 60 0 0 1 220 400", [100.0, 400.0], "Round the bend");
let spread = |s: &Session| {
let o = origins(s, id);
o.first().unwrap().distance(*o.last().unwrap())
};
let loose = spread(&s);
s.execute("type.pathOptions", &json!({"spacing": 6})).unwrap();
assert!(spread(&s) < loose - 1.0, "{} < {loose}", spread(&s));
assert_eq!(s.execute("type.pathOptions", &json!({})).unwrap()["spacing"], json!(6.0));
}
#[test]
fn dragging_the_centre_bracket_slides_the_type_and_across_the_path_flips_it() {
let mut s = session();
let id = on_line(&mut s);
let n = undo_len(&s);
// The centre bracket stands midway between the start (300) and the end of the path (500).
drag(&mut s, "selection", Point::new(400.0, 295.0), Point::new(440.0, 295.0), Mods::default());
assert_eq!(undo_len(&s), n + 1, "one step");
assert_eq!(last_step(&s), "Move Type on a Path");
let (start, end) = brackets(&s, id);
assert!(near(start, 0.6) && end.is_none(), "{start} {end:?}");
assert!(ink(&s, id).x0 > 339.0);
// Direct Selection drags it too; Cmd/Ctrl held below the path only slides it.
drag(&mut s, "directSelection", Point::new(420.0, 295.0), Point::new(380.0, 330.0), Mods { cmd: true, ..Mods::default() });
let (start, end) = brackets(&s, id);
assert!(near(start, 0.5) && end.is_none(), "{start} {end:?}");
assert!(ink(&s, id).y1 < 301.0, "still above the path");
// Across the path: flipped, on the same stretch.
drag(&mut s, "selection", Point::new(400.0, 295.0), Point::new(400.0, 330.0), Mods::default());
assert_eq!(undo_len(&s), n + 3);
let (start, end) = brackets(&s, id);
assert!(near(start, 0.0) && end.is_some_and(|e| near(e, 0.5)), "{start} {end:?}");
assert!(ink(&s, id).y0 > 299.0, "below the path");
// The step undoes as one.
s.execute("edit.undo", &json!({})).unwrap();
let (start, end) = brackets(&s, id);
assert!(near(start, 0.5) && end.is_none(), "{start} {end:?}");
}
#[test]
fn dragging_the_start_and_end_brackets_sets_where_the_type_runs() {
let mut s = session();
let id = on_line(&mut s);
// The start bracket from the middle (300) to 200.
drag(&mut s, "selection", Point::new(300.0, 290.0), Point::new(200.0, 290.0), Mods::default());
assert!(near(brackets(&s, id).0, 0.25));
// The end bracket from the end of the path to 400.
drag(&mut s, "directSelection", Point::new(500.0, 290.0), Point::new(400.0, 290.0), Mods::default());
let (start, end) = brackets(&s, id);
assert!(near(start, 0.25) && end.is_some_and(|e| near(e, 0.75)), "{start} {end:?}");
let v: Value = s.execute("type.pathOptions", &json!({})).unwrap();
assert!(near(v["end"].as_f64().unwrap(), 0.75));
let r = ink(&s, id);
assert!(r.x0 > 199.0 && r.x1 < 401.0, "between the brackets: {r:?}");
}
+1 -1
View File
@@ -73,7 +73,7 @@ fn stroke_unit_follows_the_preference() {
assert_eq!(s.stroke_unit(), Unit::Points);
s.prefs.units_stroke = "millimeters".into();
assert_eq!(s.stroke_unit(), Unit::Millimeters);
assert_eq!(s.stroke_unit().format(1.0), "0.353 mm");
assert_eq!(s.stroke_unit().format(1.0), "0.3528 mm");
}
#[test]
+101
View File
@@ -0,0 +1,101 @@
//! Arc-length parametrization of a path: the point and direction at a distance along it, and the
//! distance along it of the point nearest another (type on a path lays its glyphs out and the
//! selection tools drag its brackets by distance along the path).
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest, PathSeg, Point, Vec2};
use crate::PathData;
const ACCURACY: f64 = 1e-4;
/// A path's segments with their lengths, measured once.
#[derive(Clone, Debug, Default)]
pub struct ArcPath {
/// Segments of some length, each with the distance to its start and its length.
segs: Vec<(PathSeg, f64, f64)>,
len: f64,
closed: bool,
}
impl ArcPath {
pub fn new(path: &PathData) -> Self {
let mut segs = Vec::new();
let mut cum = 0.0;
for s in path.to_bezpath().segments() {
let l = s.arclen(ACCURACY);
if l.is_finite() && l > 1e-9 {
segs.push((s, cum, l));
cum += l;
}
}
Self { segs, len: cum, closed: path.is_closed() }
}
/// The path's length.
pub fn len(&self) -> f64 {
self.len
}
/// No segment has any length.
pub fn is_empty(&self) -> bool {
self.segs.is_empty()
}
pub fn is_closed(&self) -> bool {
self.closed
}
/// Point and unit tangent at distance `s` along the path: round and round a closed path, held
/// at an open one's ends.
pub fn at(&self, s: f64) -> (Point, Vec2) {
let s = if self.closed && self.len > 0.0 { s.rem_euclid(self.len) } else { s.clamp(0.0, self.len) };
let i = self.segs.partition_point(|(_, c, _)| *c <= s).saturating_sub(1);
let Some(&(seg, c, l)) = self.segs.get(i) else { return (Point::ZERO, Vec2::new(1.0, 0.0)) };
let t = seg.inv_arclen((s - c).min(l), ACCURACY).clamp(0.0, 1.0);
let p = seg.eval(t);
let (t0, t1) = ((t - 1e-4).max(0.0), (t + 1e-4).min(1.0));
let d = seg.eval(t1) - seg.eval(t0);
let len = d.hypot();
(p, if len > 1e-12 { d / len } else { Vec2::new(1.0, 0.0) })
}
/// The distance along the path of its point nearest `p`.
pub fn nearest(&self, p: Point) -> Option<f64> {
self.segs
.iter()
.map(|(seg, c, l)| {
let n = seg.nearest(p, ACCURACY);
(n.distance_sq, c + seg.subsegment(0.0..n.t).arclen(ACCURACY).min(*l))
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, s)| s)
}
/// [`Self::nearest`] as a fraction (0..1) of the path's length.
pub fn fraction_at(&self, p: Point) -> Option<f64> {
(self.len > 0.0).then(|| self.nearest(p)).flatten().map(|s| (s / self.len).clamp(0.0, 1.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes;
use kurbo::{Rect, Shape};
#[test]
fn points_and_nearest_distances_along_a_path() {
let line = PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1));
let a = ArcPath::new(&line);
assert!((a.len() - 100.0).abs() < 1e-6 && !a.is_closed());
assert!(a.at(25.0).0.distance(Point::new(25.0, 0.0)) < 1e-6);
// Held at an open path's ends.
assert!(a.at(150.0).0.distance(Point::new(100.0, 0.0)) < 1e-6);
assert!((a.nearest(Point::new(40.0, 30.0)).unwrap() - 40.0).abs() < 1e-6);
// Round a closed one.
let sq = ArcPath::new(&shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
assert!(sq.is_closed() && (sq.len() - 40.0).abs() < 1e-6);
assert!(sq.at(45.0).0.distance(sq.at(5.0).0) < 1e-6);
assert!(ArcPath::new(&PathData::default()).is_empty());
}
}
+2
View File
@@ -7,6 +7,7 @@
//! Coordinates are document points (1/72 in), y pointing down.
#![forbid(unsafe_code)]
pub mod arc;
pub mod bez;
pub mod hit;
pub mod path;
@@ -15,6 +16,7 @@ pub mod recognize;
pub mod shapes;
pub mod snap;
pub use arc::ArcPath;
pub use kurbo;
pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2};
pub use path::{Anchor, AnchorKind, FillRule, PathData, SubPath};
+52
View File
@@ -145,3 +145,55 @@ fn pdf_pages(pdf: &[u8]) -> usize {
.unwrap();
h.session.doc().unwrap().doc.artboards.len()
}
/// Issue #421 over MCP: RGB greys switched to CMYK with `grays: "black"` print on the black plate
/// only, in the document and in the exported PDF.
#[test]
fn document_color_mode_puts_rgb_greys_on_the_black_plate() {
let svg = tmp("greys.svg");
std::fs::write(
&svg,
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200pt" height="50pt" viewBox="0 0 200 50">
<rect x="0" y="0" width="50" height="50" fill="#000000"/><rect x="50" y="0" width="50" height="50" fill="#333333"/>
<rect x="100" y="0" width="50" height="50" fill="#808080"/><rect x="150" y="0" width="50" height="50" fill="#e6e6e6"/>
</svg>"##,
)
.unwrap();
let want = [1.0, 0.8, 0.498, 0.098];
let k_only = |h: &Headless| -> Vec<f32> {
let mut ks = vec![];
for l in &h.session.doc().unwrap().doc.layers {
l.walk(&mut |n| {
if n.children().is_none()
&& let Some(c) = n.appearance.fill_paint().color()
{
let v = serde_json::to_value(c).unwrap();
assert!(v["model"] == "cmyk" && v["c"] == 0.0 && v["m"] == 0.0 && v["y"] == 0.0, "not K only: {v}");
ks.push(v["k"].as_f64().unwrap() as f32);
}
});
}
ks
};
let close = |ks: &[f32]| ks.len() == want.len() && ks.iter().zip(want).all(|(k, w)| (k - w).abs() < 0.005);
let mut h = Headless::new();
text(&call_tool(&mut h, "open_file", &json!({"path": svg})));
let r = text(&call_tool(&mut h, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "cmyk", "grays": "black"}})));
assert_eq!(r["changed"], 4);
let ks = k_only(&h);
assert!(close(&ks), "{ks:?}");
// The PDF carries the same inks (DeviceCMYK opens as CMYK).
let pdf = text(&call_tool(&mut h, "export", &json!({"format": "pdf"})));
let mut back = Headless::new();
back.call("engine.execute", json!({"command": "document.open", "params": {"name": "greys.pdf", "dataBase64": pdf["dataBase64"]}})).unwrap();
let ks = k_only(&back);
assert!(close(&ks), "{ks:?}");
// document.open takes the same option.
let mut o = Headless::new();
let r = call_tool(&mut o, "run_command", &json!({"command": "document.open", "params": {"path": svg, "colorMode": "cmyk", "grays": "black"}}));
text(&r);
let ks = k_only(&o);
assert!(close(&ks), "{ks:?}");
let bad = call_tool(&mut o, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "rgb", "grays": "rich"}}));
assert!(bad.is_error, "{bad:?}");
}
+25 -10
View File
@@ -21,7 +21,7 @@ use vectorcraft_geom::{FillRule, PathData};
use crate::import_color::{Colors, Native};
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page};
use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
use crate::import_text::{Families, Look, Placement, TextLine, Upright};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
@@ -32,6 +32,9 @@ const TEXT_OUTLINES: &str = "<Text Outlines>";
const MAX_NESTED: u32 = 8;
/// A tiling pattern read this many times with the same art is taken to always draw it.
const PATTERN_REUSE: u32 = 16;
/// Stands for the group of art whose group couldn't be told (see [`Builder::unsure`]): it goes
/// to a hidden, non-printing layer of its page.
const UNSORTED: usize = usize::MAX;
/// Import a PDF (or PDF-compatible `.ai`) with default options.
pub fn import(bytes: &[u8]) -> Result<Document, PdfError> {
@@ -146,6 +149,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
doc.layers.clear();
let taken: Vec<String> = doc.swatches_iter().map(|s| s.name.clone()).chain(doc.swatch_groups.iter().map(|g| g.name.clone())).collect();
let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken.clone()), opts.text_as, route);
// The art of a group that is off (drawn as `all_on` turned it on) or doesn't print mustn't
// show or print on its page's layer.
b.unsure = route && ocgs.list.iter().any(|g| (all_on && !g.on) || !g.print);
b.taken = taken;
b.warnings = notes;
let cache = InterpreterCache::new();
@@ -198,19 +204,22 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
show_center_mark: false,
show_cross_hairs: false,
});
let page_layer = |b: &mut Builder<'_>, art: Vec<Arc<Node>>| {
let mut layer = Node::layer(b.id(), &format!("Page {}", number + 1), LayerColor::Preset((i % 27) as u8));
if let NodeKind::Layer { children: c, .. } = &mut layer.kind {
*c = art;
let page_layer = |b: &mut Builder<'_>, art: Vec<Arc<Node>>, shown: bool| {
let name = if shown { format!("Page {}", number + 1) } else { format!("Page {} (unsorted)", number + 1) };
let mut layer = Node::layer(b.id(), &name, LayerColor::Preset((i % 27) as u8));
layer.visible = shown;
if let NodeKind::Layer { children: c, printable, .. } = &mut layer.kind {
(*c, *printable) = (art, shown);
}
Slot::Page(Box::new(layer))
};
if parts.is_empty() {
slots.push(page_layer(&mut b, vec![]));
slots.push(page_layer(&mut b, vec![], true));
}
for (key, art) in parts {
match key {
None => slots.push(page_layer(&mut b, art)),
None => slots.push(page_layer(&mut b, art, true)),
Some(UNSORTED) => slots.push(page_layer(&mut b, art, false)),
Some(g) => {
let into = group_art.entry(g).or_insert_with(|| {
place_group(g, &ocgs, &mut slots, &mut nesting);
@@ -542,6 +551,9 @@ struct Builder<'p> {
aligned: bool,
/// Whether optional content groups become layers.
route: bool,
/// Some groups are hidden or don't print: art in marked content whose group can't be told
/// (once the scan and the interpreter part) is [`UNSORTED`] rather than the page's.
unsure: bool,
/// The marked-content sequences open: the group each marks.
marked: Vec<Option<usize>>,
/// A transparency group was just pushed: a form's (whose flags come next) or an image's.
@@ -678,6 +690,7 @@ impl<'p> Builder<'p> {
group_at: 0,
aligned: true,
route,
unsure: false,
marked: vec![],
pending: false,
nested: 0,
@@ -1521,13 +1534,15 @@ impl<'a> Device<'a> for Builder<'_> {
let at = self.tag_at;
self.tag_at += 1;
let group = match self.scan.tags.get(at) {
Some((t, g)) if self.aligned && t.as_slice() == tag => *g,
Some((t, g)) if self.aligned && *t == tag_key(tag) => *g,
_ => {
if self.route && self.aligned {
self.aligned = false;
self.warn("some art couldn't be told apart by layer and went to its page's layer");
let why = if self.scan.cut { "a page draws more than can be read for its layers: " } else { "" };
let went = if self.unsure { "a hidden, non-printing layer of its page" } else { "its page's layer" };
self.warn(&format!("{why}some art couldn't be told apart by layer and went to {went}"));
}
None
self.unsure.then_some(UNSORTED)
}
}
.filter(|_| self.route);
+41 -8
View File
@@ -21,8 +21,14 @@ use hayro_syntax::page::{Page, Resources};
/// The interpreter's limit on nested form XObjects.
const MAX_DEPTH: u32 = 50;
/// The most content operators walked per page: past it the rest of the page isn't told apart.
const MAX_OPS: usize = 2_000_000;
/// The most content operators walked per page (a guard against forms drawn within each other
/// many times over): the art past it can't be told apart by group. A drawing of a million paths
/// takes a few million; the walk reads about 15 million a second, twice as fast as the
/// interpreter draws them.
const MAX_OPS: usize = 64_000_000;
/// The most marked-content sequences noted per page (24 bytes each): past it the rest of the
/// page isn't told apart either.
const MAX_TAGS: usize = 4_000_000;
/// The most groups read.
const MAX_GROUPS: usize = 10_000;
/// The deepest layers nest in the configuration's `/Order`.
@@ -206,10 +212,35 @@ pub(crate) fn text_string(b: &[u8]) -> String {
/// One page as the interpreter walks it.
#[derive(Default)]
pub(crate) struct Scan {
/// Each marked-content sequence: its tag and the group (index into [`Ocgs::list`]) it marks.
pub tags: Vec<(Vec<u8>, Option<usize>)>,
/// Each marked-content sequence: its tag ([`tag_key`]) and the group (index into
/// [`Ocgs::list`]) it marks.
pub tags: Vec<(u64, Option<usize>)>,
/// Each form transparency group: isolated, knockout.
pub groups: Vec<(bool, bool)>,
/// The walk stopped at [`MAX_OPS`] or [`MAX_TAGS`]: the rest of the page isn't told apart.
pub cut: bool,
}
/// A marked-content tag as [`Scan::tags`] keeps it: its FNV-1a hash (the tags are only compared,
/// and a page can have millions).
pub(crate) fn tag_key(tag: &[u8]) -> u64 {
tag.iter().fold(0xcbf2_9ce4_8422_2325, |h, &b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3))
}
/// [`MAX_OPS`], lowered by tests that need a page past it.
#[cfg(not(test))]
fn max_ops() -> usize {
MAX_OPS
}
#[cfg(test)]
thread_local! {
pub(crate) static TEST_MAX_OPS: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_OPS) };
}
#[cfg(test)]
fn max_ops() -> usize {
TEST_MAX_OPS.with(std::cell::Cell::get)
}
struct Walker<'w, 'p> {
@@ -220,6 +251,7 @@ struct Walker<'w, 'p> {
fonts: &'w mut HashMap<u128, String>,
visible: Vec<bool>,
ops: usize,
max_ops: usize,
out: Scan,
/// A form XObject hidden by its own optional content was met.
hidden_form: bool,
@@ -273,7 +305,8 @@ impl Walker<'_, '_> {
self.note_fonts(res);
while let Some(op) = iter.next() {
self.ops += 1;
if self.ops > MAX_OPS {
if self.ops > self.max_ops || self.out.tags.len() >= MAX_TAGS {
self.out.cut = true;
return;
}
match op {
@@ -296,11 +329,11 @@ impl Walker<'_, '_> {
None
}
};
self.out.tags.push((bdc.0.to_vec(), group));
self.out.tags.push((tag_key(bdc.0.as_ref()), group));
}
TypedInstruction::BeginMarkedContent(bmc) => {
self.begin(true);
self.out.tags.push((bmc.0.to_vec(), None));
self.out.tags.push((tag_key(bmc.0.as_ref()), None));
}
TypedInstruction::EndMarkedContent(_) => {
self.visible.pop();
@@ -347,7 +380,7 @@ pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut HashMap<u128, String>) -> Walker<'w, 'p> {
let off = if all_on { HashSet::new() } else { ocgs.off.clone() };
let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, out: Scan::default(), hidden_form: false };
let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, max_ops: max_ops(), out: Scan::default(), hidden_form: false };
w.walk(page.typed_operations(), page.resources(), 0);
w
}
+1 -1
View File
@@ -269,7 +269,7 @@ impl TextLine {
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle);
let db = vectorcraft_text::FontDb::global();
if let Some(path) = path {
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0 };
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
t.xf = Affine::IDENTITY;
t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds);
return Some((t, self.opacity));
+55 -1
View File
@@ -1,5 +1,6 @@
//! Optional content groups become layers: name, visibility (the default configuration's or the
//! view state's), print state, lock and nesting; art that is off comes in as a hidden layer.
//! view state's), print state, lock and nesting; art that is off comes in as a hidden layer, and
//! art past what is read of a huge page never shows or prints a hidden layer.
use vectorcraft_color::Color;
use vectorcraft_doc::{Document, Node, NodeKind};
@@ -298,3 +299,56 @@ fn thousands_of_layers_hidden_ones_included_all_come_in() {
assert_eq!(d.layers.len(), N);
assert!(d.layers.iter().enumerate().all(|(i, l)| l.visible == (i % 2 == 1) && l.children().is_some_and(|c| c.len() == 1)));
}
/// Import `bytes` with the scan's operator limit lowered to `n`.
fn import_with_max_ops(bytes: &[u8], n: usize) -> ImportReport {
let old = crate::import_scan::TEST_MAX_OPS.replace(n);
let r = import_with_report(bytes, &ImportOptions::default());
crate::import_scan::TEST_MAX_OPS.set(old);
r.unwrap()
}
#[test]
fn art_past_the_scan_limit_never_shows_a_hidden_layer() {
// "Shapes" draws 100 operators before its square, then "Hidden" (off, not printed) its own.
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let filler = "q Q ".repeat(50);
let page = PdfPage {
resources: format!("/Properties << /MC0 {} 0 R /MC1 {} 0 R >>", ids[0], ids[1]),
..PdfPage::new(
100.0,
100.0,
&format!("/OC /MC0 BDC {filler}1 0 0 rg 10 10 30 30 re f EMC /OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC 0 1 0 rg 0 0 5 5 re f"),
)
};
let bytes = pdf_with_catalog(&[page], &[&objs[0], &objs[1]], &catalog, None);
// Within the limit, every group is told apart.
assert_eq!(names(&import(&bytes).unwrap()), ["Shapes", "Hidden", "Page 1"]);
let r = import_with_max_ops(&bytes, 60);
let d = &r.document;
assert_eq!(names(d), ["Shapes", "Page 1 (unsorted)", "Page 1"], "{:?}", r.warnings);
// The art drawn past the limit in a group already open stays in it.
assert!(d.layers[0].visible);
assert_eq!(colors(&d.layers[0]), [Color::rgb(1.0, 0.0, 0.0)]);
// The hidden group's art, past it, can't be told apart: it neither shows nor prints.
let unsorted = &d.layers[1];
assert!(!unsorted.visible && !printable(unsorted));
assert_eq!(colors(unsorted), [Color::rgb(0.0, 0.0, 1.0)]);
// Art outside any group is the page's, as before.
assert!(d.layers[2].visible && printable(&d.layers[2]));
assert_eq!(colors(&d.layers[2]), [Color::rgb(0.0, 1.0, 0.0)]);
assert!(r.warnings.iter().any(|w| w.contains("more than can be read") && w.contains("hidden, non-printing")), "{:?}", r.warnings);
}
#[test]
fn art_past_the_scan_limit_of_shown_layers_stays_shown() {
// Every group shows and prints: what can't be told apart goes to the page's layer, shown.
let bytes = three_groups([&ocg("A", ""), &ocg("B", ""), &ocg("C", "")], &[], "");
let r = import_with_max_ops(&bytes, 4);
let d = &r.document;
assert_eq!(names(d), ["A", "Page 1"], "{:?}", r.warnings);
assert_eq!(colors(&d.layers[0]), [Color::rgb(1.0, 0.0, 0.0)]);
assert!(d.layers[1].visible && printable(&d.layers[1]));
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)]);
assert!(r.warnings.iter().any(|w| w.contains("went to its page's layer")), "{:?}", r.warnings);
}
+1 -1
View File
@@ -96,7 +96,7 @@ fn real_text_lands_within_half_a_point_of_the_outlines() {
TextRun { text: " ok".into(), style: CharStyle { rotation: 20.0, ..style(20.0) } },
];
let mut on_path = TextObject::point(Point::ZERO, "along the arc", style(18.0));
on_path.kind = TextKind::OnPath { path: arc(), start: 0.0 };
on_path.kind = TextKind::OnPath { path: arc(), start: 0.0, end: None };
on_path.xf = Affine::IDENTITY;
for t in [plain, turned, mixed, on_path] {
let what = t.plain_text();
+7 -1
View File
@@ -1665,7 +1665,13 @@ impl Writer<'_> {
self.warn("bidirectional text is outlined to preserve shaping and visual order");
return self.text_outlines(n, t);
}
if let TextKind::OnPath { path, start } = &t.kind {
if let TextKind::OnPath { path, start, end } = &t.kind {
// `<textPath>` has a start offset only: an end bracket, Align to Path and Spacing
// keep their look as outlines.
if end.is_some() || t.path_align != vectorcraft_doc::PathAlign::Baseline || t.path_spacing != 0.0 {
self.warn("type on a path with an end bracket, Align to Path or Spacing is outlined to keep its look");
return self.text_outlines(n, t);
}
return self.text_on_path(n, t, path, *start);
}
self.note_fonts(t, &lay);
+74 -31
View File
@@ -83,8 +83,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
}
// Top-level `<g id>` elements become layers (as in the reference app); otherwise all art goes
// into "Layer 1". Top-level text joins the layer below it (the first layer if none is).
let is_layer = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !made_up(g.id()) && !im.links.contains_key(g.id()) && !im.uses.contains_key(g.id()) && is_plain(g) && im.text_slot(g).is_none());
// into "Layer 1". A clip path on one (as on an Inkscape layer, or our clipping layers) makes it
// a clipping layer. Top-level text joins the layer below it (the first layer if none is).
let is_layer = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !made_up(g.id()) && !im.links.contains_key(g.id()) && !im.uses.contains_key(g.id()) && only_clips(g) && im.text_slot(g).is_none());
let is_text = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if im.text_slot(g).is_some());
let layer_mode = top.children().iter().any(|c| is_layer(&im, c)) && top.children().iter().all(|c| is_layer(&im, c) || is_text(&im, c));
if layer_mode {
@@ -93,22 +94,35 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
for c in top.children() {
let usvg::Node::Group(g) = c else { continue };
if !is_layer(&im, c) {
let n = im.node(c, base).map(Arc::new);
match im.doc.layers.last_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
Some(ch) => ch.extend(n),
let n: Vec<_> = im.node(c, base).map(Arc::new).into_iter().collect();
match im.doc.layers.pop() {
Some(mut l) => {
im.add_unclipped(Arc::make_mut(&mut l), usize::MAX, n);
im.doc.layers.push(l);
}
None => loose.extend(n),
}
continue;
}
let mut children = std::mem::take(&mut loose);
children.extend(im.children(g, base * aff(g.transform())));
let ts = base * aff(g.transform());
let name = im.name_of(g.id()).to_string();
let mut art = im.children(g, ts);
// A clip path with no shape shows nothing of the layer: it stays, empty.
let clip = match g.clip_path() {
Some(cp) => {
art = im.clipped(cp, ts, art, &format!("layer '{name}'")).unwrap_or_default();
!art.is_empty()
}
None => false,
};
let id = im.doc.alloc_id();
let preset = (im.doc.layers.len() % vectorcraft_doc::LAYER_COLORS.len()) as u8;
let name = im.name_of(g.id()).to_string();
let mut l = Node::layer(id, &name, LayerColor::Preset(preset));
l.set_clips(clip);
if let Some(ch) = l.children_mut() {
*ch = children;
*ch = art;
}
im.add_unclipped(&mut l, 0, std::mem::take(&mut loose));
l.visible = !im.hidden.contains(g.id());
im.doc.layers.push(Arc::new(l));
}
@@ -472,7 +486,12 @@ fn aff(t: usvg::Transform) -> Affine {
}
fn is_plain(g: &usvg::Group) -> bool {
g.clip_path().is_none() && g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal
g.clip_path().is_none() && only_clips(g)
}
/// Does group `g` leave its art as it is, but for a clip path (no mask, filter, opacity or blend)?
fn only_clips(g: &usvg::Group) -> bool {
g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal
}
fn blend(b: usvg::BlendMode) -> BlendMode {
@@ -758,27 +777,8 @@ impl Importer {
inner.appearance.effects = effects.clone();
children = vec![Arc::new(inner)];
}
// A clip path clipped in turn (`<clipPath clip-path>`): one clip group in another, so
// the clips intersect.
let mut clips = vec![self.clip_node(cp, ts)?];
let mut next = cp.clip_path();
while let Some(c) = next {
if clips.len() > MAX_CLIP_NEST {
self.warn(format!("clip paths nested more than {MAX_CLIP_NEST} deep on {label}: the deeper ones are ignored"));
break;
}
clips.push(self.clip_node(c, ts)?);
next = c.clip_path();
}
let outer = clips.pop()?;
for clip in clips {
let mut ch = vec![Arc::new(clip)];
ch.extend(children);
children = vec![Arc::new(self.named("", NodeKind::Group { children: ch, clip: true }))];
}
let mut ch = vec![Arc::new(outer)];
ch.extend(children);
self.named(id, NodeKind::Group { children: ch, clip: true })
let children = self.clipped(cp, ts, children, &label)?;
self.named(id, NodeKind::Group { children, clip: true })
} else {
if children.is_empty() {
return None;
@@ -849,6 +849,49 @@ impl Importer {
Some(Box::new(mask))
}
/// `children` clipped by `cp` (on an element whose user space `ts` maps to the document): the
/// children of a clip group or clipping layer, its clipping path first. A clip path clipped in
/// turn (`<clipPath clip-path>`) nests one clip group in another, so the clips intersect.
/// `None` when the clip path has no shape (nothing shows).
fn clipped(&mut self, cp: &usvg::ClipPath, ts: Affine, mut children: Vec<Arc<Node>>, label: &str) -> Option<Vec<Arc<Node>>> {
let mut clips = vec![self.clip_node(cp, ts)?];
let mut next = cp.clip_path();
while let Some(c) = next {
if clips.len() > MAX_CLIP_NEST {
self.warn(format!("clip paths nested more than {MAX_CLIP_NEST} deep on {label}: the deeper ones are ignored"));
break;
}
clips.push(self.clip_node(c, ts)?);
next = c.clip_path();
}
let outer = clips.pop()?;
for clip in clips {
let mut ch = vec![Arc::new(clip)];
ch.extend(children);
children = vec![Arc::new(self.named("", NodeKind::Group { children: ch, clip: true }))];
}
let mut ch = vec![Arc::new(outer)];
ch.extend(children);
Some(ch)
}
/// Put `art` in layer `l` at `index` (at most its end) unclipped: a clipping mask on the layer
/// becomes a clip group in it, holding the art it clipped.
fn add_unclipped(&mut self, l: &mut Node, index: usize, art: Vec<Arc<Node>>) {
if art.is_empty() {
return;
}
if let NodeKind::Layer { children, clip: clip @ true, .. } = &mut l.kind {
*clip = false;
let clipped = std::mem::take(children);
children.push(Arc::new(self.named("", NodeKind::Group { children: clipped, clip: true })));
}
if let Some(ch) = l.children_mut() {
let i = index.min(ch.len());
ch.splice(i..i, art);
}
}
fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option<Node> {
let shapes = clip_shapes(cp, ts);
if shapes.is_empty() {
+2 -2
View File
@@ -586,7 +586,7 @@ impl Ctx {
let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0];
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0));
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start };
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start, end: None };
obj.xf = Affine::IDENTITY;
} else if vertical {
// Type on a vertical path: dy is extra advance, dx shifts across the column.
@@ -606,7 +606,7 @@ impl Ctx {
let mut bp = BezPath::new();
bp.move_to((x0, y0));
bp.line_to((x0, y0 + width + 1.0));
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start: 0.0 };
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start: 0.0, end: None };
obj.xf = Affine::IDENTITY;
} else {
let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]);
+2 -2
View File
@@ -150,7 +150,7 @@ fn text_path_becomes_type_on_a_path() {
);
let t = texts(&d);
assert_eq!(t.len(), 3);
let TextKind::OnPath { path, start } = &t[0].kind else { panic!("{:?}", t[0].kind) };
let TextKind::OnPath { path, start, .. } = &t[0].kind else { panic!("{:?}", t[0].kind) };
assert!(close(*start, 0.25, 1e-9));
let b = t[0].xf.transform_rect_bbox(path.bounds().unwrap());
assert!(close(b.x0, 10.0, 1e-6) && close(b.x1, 210.0, 1e-6) && close(b.y0, 100.0, 1e-6), "{b:?}");
@@ -310,7 +310,7 @@ fn pattern_fill_on_text_and_shapes() {
fn vertical_writing_mode_runs_down_a_path() {
let d = open(r#"<text x="100" y="20" writing-mode="tb" font-size="10">Down</text>"#);
let t = only_text(&d);
let TextKind::OnPath { path, start } = &t.kind else { panic!("{:?}", t.kind) };
let TextKind::OnPath { path, start, .. } = &t.kind else { panic!("{:?}", t.kind) };
assert_eq!(*start, 0.0);
let b = path.bounds().unwrap();
assert!(close(b.x0, b.x1, 1e-9) && close(b.y0, 20.0, 1e-9) && b.height() > 20.0, "{b:?}");
+25 -2
View File
@@ -420,19 +420,42 @@ fn text_on_path_roundtrip() {
curve.move_to((20.0, 150.0));
curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0));
let mut t = TextObject::point(Point::new(0.0, 0.0), "On a curve", CharStyle { size: 14.0, ..CharStyle::default() });
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.25 };
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.25, end: None };
let id = d.alloc_id();
let d = doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]);
let r = roundtrip(&d);
let a = art(&r);
let NodeKind::Text(t) = &a[0].kind else { panic!() };
assert_eq!(t.plain_text(), "On a curve");
let TextKind::OnPath { path, start } = &t.kind else { panic!("not type on a path: {:?}", t.kind) };
let TextKind::OnPath { path, start, .. } = &t.kind else { panic!("not type on a path: {:?}", t.kind) };
assert!((start - 0.25).abs() < 1e-6, "start {start}");
let doc_path = path.bounds().map(|b| t.xf.transform_rect_bbox(b)).unwrap();
assert!(close_rect(doc_path, PathData::from_bezpath(&curve).bounds().unwrap(), 0.01), "{doc_path:?}");
}
/// An end bracket, Align to Path or Spacing, which `<textPath>` can't express, keep their look as
/// outlines (#429).
#[test]
fn text_on_path_options_svg_cannot_express_are_outlined() {
let mut curve = vectorcraft_geom::BezPath::new();
curve.move_to((20.0, 150.0));
curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0));
for set in [0, 1, 2] {
let mut d = Document::new(200.0, 200.0);
let mut t = TextObject::point(Point::ZERO, "On a curve", CharStyle { size: 14.0, ..CharStyle::default() });
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: (set == 0).then_some(0.8) };
if set == 1 {
t.path_align = vectorcraft_doc::PathAlign::Center;
}
if set == 2 {
t.path_spacing = 4.0;
}
let id = d.alloc_id();
let svg = export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &ExportOptions::default());
assert!(!svg.contains("<textPath"), "{set}: {svg}");
}
}
#[test]
fn styling_modes_roundtrip() {
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::StyleEntities, Styling::InternalCss] {
+74 -2
View File
@@ -1,6 +1,6 @@
//! Symbols export as one `<symbol>` def and a `<use>` per instance (instances the def can't stand
//! for get their own art), hidden layers are kept hidden when asked and come back hidden, and the
//! editing data notices edits made elsewhere.
//! for get their own art), hidden layers are kept hidden when asked and come back hidden, clipped
//! layers come in as clipping layers, and the editing data notices edits made elsewhere.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -205,6 +205,78 @@ fn hidden_layers_are_kept_hidden_when_asked() {
}
}
/// Each layer's art: clipping layer or not, and the kinds of its children.
fn layer_art(d: &Document) -> Vec<(String, bool, Vec<&'static str>)> {
d.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.clips(), l.children().unwrap().iter().map(|c| c.kind_label()).collect())).collect()
}
/// Two Inkscape layers clipped to the same window (#420), in pixels so resvg draws them at our scale.
const CLIPPED_LAYERS: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="200" height="100" viewBox="0 0 200 100">
<defs><clipPath id="window"><rect x="5" y="5" width="190" height="90"/></clipPath></defs>
<g id="CUT" inkscape:groupmode="layer" inkscape:label="Cut lines" clip-path="url(#window)"><rect x="0" y="0" width="100" height="100" fill="#f00"/></g>
<g id="HATCH" inkscape:groupmode="layer" inkscape:label="HATCH" clip-path="url(#window)"><rect x="100" y="0" width="100" height="100" fill="#00f"/></g>
</svg>"##;
#[test]
fn clipped_layers_come_in_as_clipping_layers() {
let d = import(CLIPPED_LAYERS).unwrap();
assert_eq!(
layer_art(&d),
[("Cut lines".to_string(), true, vec!["Path", "Path"]), ("HATCH".to_string(), true, vec!["Path", "Path"])],
"{:?}",
d.layers
);
for l in &d.layers {
let clip = &l.children().unwrap()[0];
assert!(matches!(clip.kind, NodeKind::Path { clipping: true, .. }) && clip.name.as_deref() == Some("window"));
assert_eq!(clip.geometric_bounds().unwrap(), Rect::new(5.0, 5.0, 195.0, 95.0));
}
// Drawn as the SVG is: each layer cut to the window.
assert_similar(&render_artboard(&d), &resvg_render(CLIPPED_LAYERS, 200, 100), 24.0, 0.002);
// The issue's file (in points): two clipping layers, the names kept.
let pt = CLIPPED_LAYERS.replace("width=\"200\" height=\"100\"", "width=\"200pt\" height=\"100pt\"");
let d = import(&pt).unwrap();
assert_eq!(d.layers.iter().map(|l| (l.name.as_deref().unwrap(), l.clips())).collect::<Vec<_>>(), [("Cut lines", true), ("HATCH", true)]);
}
#[test]
fn clipping_layers_round_trip() {
let mut d = import(CLIPPED_LAYERS).unwrap();
// A clip path clipped in turn: the layer's own clip outermost, the other one inside it.
let nested = CLIPPED_LAYERS.replace(
"<defs>",
"<defs><clipPath id=\"left\"><rect x=\"0\" y=\"0\" width=\"60\" height=\"100\"/></clipPath><clipPath id=\"inner\" clip-path=\"url(#left)\"><rect x=\"20\" y=\"20\" width=\"160\" height=\"60\"/></clipPath>",
);
let nested = nested.replacen("url(#window)", "url(#inner)", 1);
let n = import(&nested).unwrap();
assert_eq!(layer_art(&n)[0], ("Cut lines".to_string(), true, vec!["Path", "Clip Group"]));
assert_similar(&render_artboard(&n), &resvg_render(&nested, 200, 100), 24.0, 0.002);
d.layers.push(n.layers[0].clone());
let svg = export(&d, &ExportOptions::default());
let back = import(&svg).unwrap();
assert_eq!(layer_art(&back), layer_art(&d), "{svg}");
assert_similar(&render_artboard(&back), &render_artboard(&d), 24.0, 0.002);
}
#[test]
fn text_joining_a_clipping_layer_stays_unclipped() {
// Text beside clipped layers isn't in their clip: the layer keeps its clip as a clip group.
let svg = CLIPPED_LAYERS
.replace("<g id=\"CUT\"", "<text x=\"1\" y=\"99\">Before</text><g id=\"CUT\"")
.replace("</svg>", "<text x=\"150\" y=\"99\">After</text></svg>");
let d = import(&svg).unwrap();
assert_eq!(
layer_art(&d),
[("Cut lines".to_string(), false, vec!["Type", "Clip Group"]), ("HATCH".to_string(), false, vec!["Clip Group", "Type"])],
"{:?}",
d.layers
);
for l in &d.layers {
let g = l.children().unwrap().iter().find(|c| c.clips()).unwrap();
assert_eq!(g.children().unwrap().iter().map(|c| c.kind_label()).collect::<Vec<_>>(), ["Path", "Path"]);
}
}
#[test]
fn hidden_groups_others_use_stay_hidden() {
// A display:none group a <use> refers to is a template, not a hidden layer.
+78 -66
View File
@@ -2,9 +2,10 @@
use std::ops::Range;
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2};
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2};
use unicode_bidi::{BidiInfo, Level};
use vectorcraft_doc::{CharStyle, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathEffect, TextKind, TextObject};
use vectorcraft_doc::{CharStyle, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject};
use vectorcraft_geom::{ArcPath, PathData};
use crate::composer::{Breakpoint, compose};
use crate::fontdb::FontDb;
@@ -56,7 +57,7 @@ impl Ctx<'_> {
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
let src = self.db.outline(&g.face, g.gid);
// A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an
// upright one in vertical type by turning about a point that much higher.
// upright one in vertical type by moving it up the column once it stands upright.
let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g);
let lead = if upright { 0.0 } else { g.lead };
let local =
@@ -79,10 +80,11 @@ impl Ctx<'_> {
// after the cell, and must not push the glyph off the centre line).
let em = self.style_at(g.byte).size;
let cell = if g.adv > 0.0 { upright_cell(g) } else { advance };
m = Affine::rotate_about(
-std::f64::consts::FRAC_PI_2,
Point::new(origin.x + cell * 0.5 - g.lead, origin.y - upright_centre(g, cell, em)),
) * m;
// A leading space taken off moves it up the column after the turn (moving the
// turning point instead would move it across the column too).
m = Affine::translate((-g.lead, 0.0))
* Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em)))
* m;
}
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
@@ -183,7 +185,7 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect();
flow(&mut cx, &paras, &t.para, Some(&regions));
}
TextKind::OnPath { path, start } => on_path(&mut cx, &paras, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect),
TextKind::OnPath { path, .. } => on_path(&mut cx, &paras, t, path),
}
if vertical && !is_on_path {
// Glyph origins, outlines and transforms to text space (lines stay in line space).
@@ -969,6 +971,18 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
let est = if i < n { Metrics::of(&sg[i]) } else { pm };
let first_line = li_para == 0;
let ind_l = para.left_indent + if first_line { para.first_line_indent } else { 0.0 };
// Mojikumi: an opening bracket starting a wrapped line is set flush with the line's
// start (the space before it goes), and the line has that much more room.
if !first_line
&& !rtl
&& para.mojikumi == Mojikumi::LineEndHalf
&& let Some(g) = sg.get_mut(i)
&& g.lead <= 0.0
&& let Some(h) = punct_half(g, Punct::Opening)
{
g.adv -= h;
g.lead += h;
}
// Place, break, then settle the baseline on the line's real metrics (moving on to the
// next row/column if it no longer fits).
let (baseline, x0, x1, end, hyph, m) = loop {
@@ -1155,43 +1169,31 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
}
}
/// Arc-length parameterised path.
struct ArcPath {
segs: Vec<(PathSeg, f64, f64)>,
len: f64,
}
impl ArcPath {
fn new(p: &BezPath) -> Self {
let mut segs = Vec::new();
let mut cum = 0.0;
for s in p.segments() {
let l = s.arclen(1e-4);
if l > 1e-9 {
segs.push((s, cum, l));
cum += l;
/// The distance along type on a path each glyph takes: its advance measured `spacing` points above
/// the path (Type on a Path Options › Spacing), so glyphs close up round the outside of a curve and
/// open up round the inside; just the advances without spacing. Glyphs start `from` along the path.
fn path_steps(ap: &ArcPath, glyphs: &[SGlyph], from: f64, spacing: f64) -> Vec<f64> {
let mut s = from;
glyphs
.iter()
.map(|g| {
let mut step = g.adv;
if spacing != 0.0 && g.adv > 1e-9 {
// How far the path turns across the glyph: positive bending away from its top.
let ((_, a), (_, b)) = (ap.at(s), ap.at(s + g.adv));
let curvature = a.cross(b).atan2(a.dot(b)) / g.adv;
step = g.adv / (1.0 + curvature * spacing).clamp(0.25, 4.0);
}
}
Self { segs, len: cum }
}
/// Point and unit tangent at arc length `s`.
fn at(&self, s: f64) -> (Point, Vec2) {
let s = s.clamp(0.0, self.len);
let i = self.segs.partition_point(|(_, c, _)| *c <= s).saturating_sub(1);
let (seg, c, l) = self.segs[i];
let t = seg.inv_arclen((s - c).min(l), 1e-4).clamp(0.0, 1.0);
let p = seg.eval(t);
let (t0, t1) = ((t - 1e-4).max(0.0), (t + 1e-4).min(1.0));
let d = seg.eval(t1) - seg.eval(t0);
let len = d.hypot();
(p, if len > 1e-12 { d / len } else { Vec2::new(1.0, 0.0) })
}
s += step;
step
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &BezPath, start: f64, closed: bool, effect: PathEffect) {
/// Lay type on a path out along `path` (text space), between its brackets.
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &PathData) {
cx.out.on_path = true;
let para = &t.para;
let mut sg = Vec::new();
// The first paragraph's direction aligns the line (Auto) and sets the caret's.
let mut rtl = None;
@@ -1213,7 +1215,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
(s.0, s.1)
});
let text_len = cx.text.len();
if ap.segs.is_empty() {
if ap.is_empty() {
cx.out.overflow = !sg.is_empty();
cx.out.lines.push(LineInfo {
rtl,
@@ -1230,32 +1232,43 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
});
return;
}
let centre = path.bounding_box().center();
let s_start = start.clamp(0.0, 1.0) * ap.len;
let avail = if closed { ap.len } else { ap.len - s_start };
let w: f64 = sg.iter().map(|g| g.adv).sum();
let centre = path.bounds().unwrap_or_default().center();
let (from, to) = t.kind.path_span().unwrap_or((0.0, 1.0));
let s_start = from * ap.len();
let avail = (to - from) * ap.len();
let spacing = if t.path_spacing.is_finite() { t.path_spacing } else { 0.0 };
let mut steps = path_steps(&ap, &sg, s_start, spacing);
let w: f64 = steps.iter().sum();
let s0 = match para.justify {
Justify::Auto if rtl => s_start + (avail - w).max(0.0),
Justify::Center | Justify::JustifyCenter => s_start + ((avail - w) * 0.5).max(0.0),
Justify::Right | Justify::JustifyRight => s_start + (avail - w).max(0.0),
_ => s_start,
};
if spacing != 0.0 && s0 != s_start {
// Spaced from where the alignment puts them, the curve under them is another.
steps = path_steps(&ap, &sg, s0, spacing);
}
// Align to Path: how far down (glyph space) the type moves to run its ascender, centre or
// descender along the path instead of its baseline.
let rise = match t.path_align {
PathAlign::Baseline => 0.0,
PathAlign::Ascender => m.0,
PathAlign::Descender => -m.1,
PathAlign::Center => (m.0 - m.1) * 0.5,
};
let mut x = 0.0;
for g in &sg {
for (g, &step) in sg.iter().zip(&steps) {
let s = s0 + x;
if s + g.adv - s_start > avail + 1e-6 {
if s + step - s_start > avail + 1e-6 {
cx.out.overflow = true;
break;
}
let mut mid = s + g.adv * 0.5;
if closed {
mid = mid.rem_euclid(ap.len);
}
let (p, dir) = ap.at(mid);
let (p, dir) = ap.at(s + step * 0.5);
let angle = dir.y.atan2(dir.x);
let half = Affine::translate((-g.adv * 0.5, 0.0));
let half = Affine::translate((-g.adv * 0.5, rise));
// Glyph space: x along the advance, y down from the baseline; `pre` maps it onto the path.
let pre = match effect {
let pre = match t.path_effect {
PathEffect::Rainbow => Affine::translate(p.to_vec2()) * Affine::rotate(angle) * half,
// x axis along the tangent, y axis stays vertical.
PathEffect::Skew => Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, 0.0, 1.0, 0.0, 0.0]) * half,
@@ -1264,10 +1277,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
let sx = if dir.x < 0.0 { -1.0 } else { 1.0 };
Affine::translate(p.to_vec2()) * Affine::new([sx, 0.0, -dir.y * sx, dir.x * sx, 0.0, 0.0]) * half
}
PathEffect::StairStep => {
let s_left = if closed { s.rem_euclid(ap.len) } else { s };
Affine::translate(ap.at(s_left).0.to_vec2())
}
PathEffect::StairStep => Affine::translate(ap.at(s).0.to_vec2()) * Affine::translate((0.0, rise)),
// x axis along the tangent; vertical edges point at the path's centre (kept on the glyph's
// up side, and never closer than ~17° to the baseline so glyphs stay legible).
PathEffect::Gravity => {
@@ -1278,17 +1288,19 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
up = -up;
}
if up.dot(n) < 0.3 {
let t = up - n * up.dot(n);
up = n * 0.3 + t / t.hypot().max(1e-9) * (1.0 - 0.09f64).sqrt();
let along = up - n * up.dot(n);
up = n * 0.3 + along / along.hypot().max(1e-9) * (1.0 - 0.09f64).sqrt();
}
Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, -up.x, -up.y, 0.0, 0.0]) * half
}
};
cx.emit(g, pre, p - dir * (g.adv * 0.5), angle, g.adv, 0);
x += g.adv;
// The caret's baseline moves with the type.
let origin = p - dir * (g.adv * 0.5) + Vec2::new(-dir.y, dir.x) * rise;
cx.emit(g, pre, origin, angle, g.adv, 0);
x += step;
}
let (ps, _) = ap.at(if closed { s0.rem_euclid(ap.len) } else { s0 });
let (pe, _) = ap.at(if closed { (s0 + x).rem_euclid(ap.len) } else { s0 + x });
let (ps, _) = ap.at(s0);
let (pe, _) = ap.at(s0 + x);
cx.out.lines.push(LineInfo {
rtl,
baseline: ps.y,
@@ -1300,7 +1312,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
end: text_len,
glyph_start: 0,
glyph_end: cx.out.glyphs.len(),
avail: (0.0, ap.len),
avail: (0.0, ap.len()),
});
}
-18
View File
@@ -328,24 +328,6 @@ pub fn caret_vertical(layout: &TextLayout, byte: usize, delta: i32, goal_x: f64)
byte_in_line(layout, li as usize, goal_x)
}
/// Nearest point on `path` to `p`: (fraction of the path's arc length 0..1, distance). Used to
/// start type on a path where the user clicked.
pub fn path_fraction_at(path: &BezPath, p: Point) -> (f64, f64) {
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
let mut total = 0.0;
let mut best = (0.0, f64::INFINITY);
for seg in path.segments() {
let len = seg.arclen(1e-3);
let n = seg.nearest(p, 1e-4);
let d = n.distance_sq.sqrt();
if d < best.1 {
best = (total + seg.subsegment(0.0..n.t).arclen(1e-3), d);
}
total += len;
}
if total <= 0.0 { (0.0, best.1) } else { ((best.0 / total).clamp(0.0, 1.0), best.1) }
}
/// Highlight quads (text space, clockwise from top-left) covering the selected bytes `a..b`.
pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4]> {
let (a, b) = (a.min(b), a.max(b));
+6 -6
View File
@@ -271,7 +271,7 @@ fn on_path_placement() {
line.move_to((0.0, 50.0));
line.line_to((500.0, 50.0));
let mut t = point("Path", style(20.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.1 };
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.1, end: None };
let l = layout(db(), &t);
assert!(l.on_path && !l.overflow);
assert_eq!(l.glyphs.len(), 4);
@@ -283,7 +283,7 @@ fn on_path_placement() {
let mut v = BezPath::new();
v.move_to((0.0, 0.0));
v.line_to((0.0, 300.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&v), start: 0.0 };
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&v), start: 0.0, end: None };
let l = layout(db(), &t);
for g in &l.glyphs {
assert!((g.angle - std::f64::consts::FRAC_PI_2).abs() < 1e-3);
@@ -302,7 +302,7 @@ fn on_path_effects_orient_glyphs() {
diag.move_to((0.0, 0.0));
diag.line_to((400.0, 400.0));
let mut t = point("H", style(40.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&diag), start: 0.1 };
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&diag), start: 0.1, end: None };
let bbox = |t: &TextObject| layout(db(), t).glyphs[0].outline.bounding_box();
let rainbow = bbox(&t);
t.path_effect = PathEffect::StairStep;
@@ -315,7 +315,7 @@ fn on_path_effects_orient_glyphs() {
assert!(skew.height() > stair.height() && skew.width() < rainbow.width() + 1e-6, "{skew:?}");
// Gravity on a circle: glyphs point away from the centre (same as Rainbow on a circle).
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0 };
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0, end: None };
t.path_effect = PathEffect::Rainbow;
let a = layout(db(), &t).glyphs[0].outline.bounding_box();
t.path_effect = PathEffect::Gravity;
@@ -329,7 +329,7 @@ fn on_path_effects_orient_glyphs() {
fn on_path_circle_and_overflow() {
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);
let mut t = point("Around the circle", style(14.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0 };
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0, end: None };
let l = layout(db(), &t);
assert!(!l.overflow);
for g in &l.glyphs {
@@ -339,7 +339,7 @@ fn on_path_circle_and_overflow() {
let mut short = BezPath::new();
short.move_to((0.0, 0.0));
short.line_to((30.0, 0.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&short), start: 0.0 };
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&short), start: 0.0, end: None };
let l = layout(db(), &t);
assert!(l.overflow && l.glyphs.len() < t.plain_text().len());
}
+43 -1
View File
@@ -76,7 +76,7 @@ fn area_type_starts_at_the_right_edge_and_wraps_to_the_left() {
#[test]
fn type_on_a_path_stays_horizontal() {
let mut t = vertical("ab");
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0 };
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0, end: None };
assert!(!layout(FontDb::global(), &t).vertical);
}
@@ -325,6 +325,9 @@ fn mojikumi_halves_line_end_and_consecutive_punctuation() {
let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 };
let moved = along(off.glyphs[1].outline.bounding_box()) - along(on.glyphs[1].outline.bounding_box());
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: the second 「 is drawn half an em earlier ({moved})");
let across = |r: Rect| if vertical_type { r.x0 } else { r.y0 };
let drift = across(off.glyphs[1].outline.bounding_box()) - across(on.glyphs[1].outline.bounding_box());
assert!(drift.abs() < 0.01, "vertical {vertical_type}: and not moved across the line ({drift})");
// Text without punctuation is untouched.
assert_eq!(adv("一二", Mojikumi::LineEndHalf), adv("一二", Mojikumi::None));
}
@@ -364,3 +367,42 @@ fn mojikumi_spaces_japanese_from_latin_by_a_quarter_em() {
let off = lay("第10回", Mojikumi::None, true);
assert!(on.glyphs.iter().zip(&off.glyphs).all(|(a, b)| (a.advance - b.advance).abs() < 0.01));
}
/// Mojikumi (JLREQ 3.1.5): with Line-end Punctuation Half Width, an opening bracket starting a
/// wrapped line is set flush with the line's start (the space before it goes), giving the line half
/// an em more room; at the start of a paragraph it keeps its full width. Horizontal and vertical.
/// Needs a font with full-width Japanese punctuation (skipped without one).
#[test]
fn mojikumi_sets_an_opening_bracket_flush_at_the_start_of_a_wrapped_line() {
use vectorcraft_doc::Mojikumi;
// 20 pt type in a frame three and a half ems across the lines.
let lay = |text: &str, m: Mojikumi, vertical_type: bool| {
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
t.xf = Affine::IDENTITY;
t.vertical = vertical_type;
t.para.mojikumi = m;
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 70.0) } else { Rect::new(0.0, 0.0, 70.0, 200.0) };
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
layout(FontDb::global(), &t)
};
if (lay("一「", Mojikumi::None, false).glyphs[1].advance - 20.0).abs() > 2.0 {
return; // no font with full-width punctuation here
}
for vertical_type in [false, true] {
let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 };
// 一二三 / 「四五六: the bracket can't end the first line, so it starts the second.
let (on, off) = (lay("一二三「四五六", Mojikumi::LineEndHalf, vertical_type), lay("一二三「四五六", Mojikumi::None, vertical_type));
let moved = along(off.glyphs[3].outline.bounding_box()) - along(on.glyphs[3].outline.bounding_box());
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: 「 is drawn half an em earlier ({moved})");
let across = |r: Rect| if vertical_type { r.x0 } else { r.y0 };
let drift = across(off.glyphs[3].outline.bounding_box()) - across(on.glyphs[3].outline.bounding_box());
assert!(drift.abs() < 0.01, "vertical {vertical_type}: and not moved across the line ({drift})");
assert!((on.glyphs[3].advance - 10.0).abs() < 0.01, "vertical {vertical_type}: {}", on.glyphs[3].advance);
// The half em it gave up lets 六 stay on the line.
assert_eq!(on.glyphs[6].line, on.glyphs[3].line, "vertical {vertical_type}");
assert_ne!(off.glyphs[6].line, off.glyphs[3].line, "vertical {vertical_type}");
// At the start of a paragraph the bracket keeps its full width.
let first = lay("「一」", Mojikumi::LineEndHalf, vertical_type);
assert!((first.glyphs[0].advance - 20.0).abs() < 0.01, "vertical {vertical_type}: {}", first.glyphs[0].advance);
}
}
+160 -15
View File
@@ -1,17 +1,21 @@
//! Direct Selection (A) and Group Selection tools.
//!
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
//! path's anchors on that segment, drag to move selected anchors, drag a direction handle to
//! path's anchors on that segment, drag to move selected anchors (the one pressed on snaps to
//! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45°
//! steps), drag a direction handle to
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
//! it), marquee to select anchors, drag a live rectangle's corner widget to round its corners
//! (the selected ones when anchors are selected; Alt-click cycles their kind, double-click opens
//! the Corners dialog).
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group.
//! Both move objects selected as a whole as the Selection tool does, snapping alike.
//! 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
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size.
//! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]).
//!
//! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors
//! (the fill, or Alt, selects the whole path); dragging the segment bends it if it's curved, else
@@ -26,16 +30,21 @@ use vectorcraft_geom::{PathData, Point, Rect};
use crate::bbox::move_delta;
use crate::corners::{self, CornerDrag, over_widget};
use crate::guides::{PointSnap, Targets};
use crate::meshedit::MeshEdit;
use crate::pathtype::{self, BracketDrag, over_bracket};
use crate::rulerguide::GuideEdit;
use crate::select::{is_area_type, matrix_json};
use crate::select::{MoveSnap, is_area_type, matrix_json};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Clone, Debug)]
enum State {
Idle,
/// Dragging the selected anchors, which move alike: `grab` is where the anchor pressed on (or
/// the pointer on a segment) was, the point that snaps.
MoveAnchors {
start: Point,
grab: Point,
began: bool,
},
MoveObject {
@@ -63,6 +72,8 @@ enum State {
add: bool,
},
Corner(CornerDrag),
/// Dragging a bracket of type on a path.
Bracket(BracketDrag),
/// Dragging a point of a blend's spine from `from`.
SpinePoint {
id: NodeId,
@@ -94,8 +105,12 @@ pub struct DirectSelectionTool {
/// The spine point last clicked (blend, anchor): its handles show and can be dragged.
spine: Option<(NodeId, usize)>,
mesh: MeshEdit,
/// Smart guides of the handle being dragged.
/// Smart guides of the handle, anchors or objects being dragged.
guides: Vec<Overlay>,
/// What the dragged anchors snap to, gathered when the drag begins.
anchor_snap: Option<PointSnap>,
/// What the objects being moved snap to, gathered when the move begins.
move_snap: Option<MoveSnap>,
/// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is.
hover: Option<(NodeId, Point)>,
guide: GuideEdit,
@@ -103,7 +118,17 @@ pub struct DirectSelectionTool {
impl DirectSelectionTool {
pub fn new(group: bool) -> Self {
Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default(), guides: vec![], hover: None, guide: GuideEdit::default() }
Self {
group,
state: State::Idle,
spine: None,
mesh: MeshEdit::default(),
guides: vec![],
anchor_snap: None,
move_snap: None,
hover: None,
guide: GuideEdit::default(),
}
}
}
@@ -170,12 +195,12 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
v
}
/// Anchor of any visible path or area type frame under `p`, topmost first.
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
/// Anchor of any visible path or area type frame under `p`, topmost first: (id, si, ai, where it is).
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, Point)> {
let owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n));
owners.into_iter().find_map(|id| {
let pd = cx.doc.node(id).and_then(editable_path)?;
pd.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(si, ai, _)| (id, si, ai))
pd.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(si, ai, a)| (id, si, ai, a.p))
})
}
@@ -289,6 +314,10 @@ impl Tool for DirectSelectionTool {
self.state = State::Corner(c);
return vec![];
}
if let Some(b) = BracketDrag::hit(cx, ev) {
self.state = State::Bracket(b);
return vec![];
}
if let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id))
&& let Some(out) = hit_spine_handle(cx, sel, p, tol)
{
@@ -310,12 +339,12 @@ impl Tool for DirectSelectionTool {
self.state = State::SpinePoint { id, anchor, from, start: p, began: false };
return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
}
if let Some((id, si, ai)) = hit_anchor(cx, p, tol) {
if let Some((id, si, ai, grab)) = hit_anchor(cx, p, tol) {
if cx.doc.node(id).is_some_and(is_area_type) {
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
}
let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai)));
self.state = State::MoveAnchors { start: p, began: false };
self.state = State::MoveAnchors { start: p, grab, began: false };
if ev.mods.shift {
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))];
}
@@ -346,7 +375,7 @@ impl Tool for DirectSelectionTool {
self.state = if s.curved {
State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false }
} else {
State::MoveAnchors { start: p, began: false }
State::MoveAnchors { start: p, grab: p, began: false }
};
if selected {
return vec![];
@@ -367,16 +396,25 @@ impl Tool for DirectSelectionTool {
self.state = State::Marquee { start: p, cur: p, add: ev.mods.shift };
vec![]
}
(PointerKind::Drag, State::MoveAnchors { start, began }) => {
(PointerKind::Drag, State::MoveAnchors { start, grab, began }) => {
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Move".into()));
self.state = State::MoveAnchors { start, began: true };
self.state = State::MoveAnchors { start, grab, began: true };
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
}
let mut d = move_delta(start, p, ev.mods.shift);
// The grabbed anchor snaps and the others follow it; Shift keeps the angle instead.
self.guides.clear();
if !ev.mods.shift
&& let Some(snap) = &self.anchor_snap
{
let (q, guides) = snap.snap(cx, grab + d);
(d, self.guides) = (q - grab, guides);
}
let d = move_delta(start, p, ev.mods.shift);
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
out
}
@@ -404,8 +442,12 @@ impl Tool for DirectSelectionTool {
}
out.push(Action::Begin("Move".into()));
self.state = State::MoveObject { start, began: true };
self.move_snap = Some(MoveSnap::new(cx));
}
let mut d = move_delta(start, p, ev.mods.shift);
if let Some(snap) = &self.move_snap {
(d, self.guides) = snap.snap(cx, start, d);
}
let d = move_delta(start, p, ev.mods.shift);
out.push(Action::Preview(
"object.transform".into(),
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}),
@@ -468,6 +510,15 @@ impl Tool for DirectSelectionTool {
self.state = State::Idle;
c.finish()
}
(PointerKind::Drag, State::Bracket(mut b)) => {
let out = b.drag(cx, p, ev.mods.cmd);
self.state = State::Bracket(b);
out
}
(PointerKind::Up, State::Bracket(b)) => {
self.state = State::Idle;
b.finish()
}
(
PointerKind::Up,
State::MoveAnchors { began, .. }
@@ -477,6 +528,8 @@ impl Tool for DirectSelectionTool {
| State::TypeArea { began, .. },
) => {
self.state = State::Idle;
self.guides.clear();
(self.anchor_snap, self.move_snap) = (None, None);
if began { vec![Action::Commit] } else { vec![] }
}
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
@@ -516,8 +569,9 @@ impl Tool for DirectSelectionTool {
out.extend(self.mesh.overlays(cx));
if !self.group {
out.extend(frame_overlays(cx));
out.extend(pathtype::overlays(cx));
}
if matches!(self.state, State::Handle { .. }) {
if matches!(self.state, State::Handle { .. } | State::MoveAnchors { .. } | State::MoveObject { .. }) {
out.extend(self.guides.iter().cloned());
}
if let Some((id, p)) = self.hover.filter(|_| matches!(self.state, State::Idle)) {
@@ -532,6 +586,9 @@ impl Tool for DirectSelectionTool {
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p)) {
return Cursor::CornerRadius;
}
if !self.group && (matches!(self.state, State::Bracket(_)) || over_bracket(cx, p)) {
return Cursor::PathBracket;
}
// A press on the highlighted anchor picks it, not the guide.
self.guide.cursor(cx, p).filter(|_| self.hover.is_none() || self.guide.busy()).unwrap_or(Cursor::ArrowHollow)
}
@@ -793,4 +850,92 @@ mod tests {
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0));
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}");
}
/// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B).
fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) {
let (mut d, a) = doc_with_rect();
let l = d.layers[0].id;
let b = d.alloc_id();
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap();
(d, a, b)
}
/// The anchors `anchors` of path `id` direct-selected.
fn anchors_of(id: NodeId, anchors: &[AnchorRef]) -> Selection {
let mut s = Selection::default();
s.set([id]);
s.anchors.insert(id, anchors.iter().copied().collect());
s
}
/// Press at `from`, drag to `to`: the move the drag previews, and the smart guide labels shown.
fn drag(t: &mut DirectSelectionTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), mods: Mods) -> (Value, Vec<String>) {
t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1).with_mods(mods));
let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(mods));
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
let labels = t
.overlays(cx)
.into_iter()
.filter_map(|o| match o {
Overlay::Label { text, .. } => Some(text),
_ => None,
})
.collect();
assert_eq!(t.pointer(cx, &PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(mods)), vec![Action::Commit]);
assert!(t.overlays(cx).iter().all(|o| !matches!(o, Overlay::Label { .. })), "the guides go with the drag");
(v.clone(), labels)
}
/// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors
/// of its own path, and with several selected the one pressed on snaps, the others following.
#[test]
fn dragged_anchors_snap_to_points_segments_and_smart_guides() {
let (d, _, b) = two_squares();
let p = paint();
let none = Mods::default();
let one = anchors_of(b, &[(0, 0)]);
let c = cx(&d, &one, &p);
let mut t = DirectSelectionTool::new(false);
// B's top-left corner, pressed 1 pt off, onto A's bottom-right one.
let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), none);
assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -100.0}), vec!["anchor".to_string()]));
// Onto A's right side.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 150.0), none);
assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -150.0}), vec!["path".to_string()]));
// In line with B's own bottom-left corner, which stays put.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (303.0, 330.0), none);
assert_eq!((v, labels), (json!({"dx": 0.0, "dy": 30.0}), vec!["align".to_string()]));
// Shift keeps the move at 45° steps, unsnapped.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), Mods { shift: true, ..none });
let s = move_delta(Point::new(300.0, 300.0), Point::new(203.0, 199.0), true);
assert_eq!((v, labels), (json!({"dx": s.x, "dy": s.y}), vec![]));
// Two anchors: the one pressed on (B's top-right) lands on A's corner.
let two = anchors_of(b, &[(0, 0), (0, 1)]);
let c = cx(&d, &two, &p);
let (v, _) = drag(&mut t, &c, (400.0, 300.0), (202.0, 198.0), none);
assert_eq!(v, json!({"dx": -200.0, "dy": -100.0}));
// Smart Guides off: Snap to Point pulls within its distance (2 px) only.
let c = ToolContext { smart_guides: false, ..cx(&d, &one, &p) };
assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -100.0, "dy": -100.0}));
assert_eq!(drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), none).0, json!({"dx": -97.0, "dy": -101.0}));
let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &one, &p) };
assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -99.0, "dy": -101.0}));
}
/// Group Selection (and Direct Selection on a whole path) moves objects snapping as the
/// Selection tool does: B's left side lines up with A's right one.
#[test]
fn moved_objects_snap_with_smart_guides() {
let (d, _, b) = two_squares();
let p = paint();
let mut s = Selection::default();
s.set([b]);
let c = cx(&d, &s, &p);
for group in [true, false] {
let mut t = DirectSelectionTool::new(group);
let (v, _) = drag(&mut t, &c, (350.0, 350.0), (253.0, 351.0), Mods::default());
assert_eq!(v["matrix"], json!([1.0, 0.0, 0.0, 1.0, -100.0, 1.0]), "group: {group}");
}
}
}
+131 -20
View File
@@ -1,9 +1,11 @@
//! Smart Guides: snapping to anchors, object bounds (edges/centres), artboards, with the magenta
//! construction lines and labels Illustrator users expect.
//! Smart Guides: snapping to anchors, path segments (for dragged anchors), object bounds
//! (edges/centres), artboards, with the magenta construction lines and labels Illustrator users
//! expect.
use vectorcraft_doc::hit::{HitOptions, hit_test};
use vectorcraft_doc::{Document, NodeId, NodeKind, OrientedBox};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use vectorcraft_doc::{Document, NodeId, NodeKind, OrientedBox, Selection};
use vectorcraft_geom::kurbo::ParamCurveNearest;
use vectorcraft_geom::{Affine, Line, ParamCurve, PathSeg, Point, Rect, SubPath, Vec2};
use crate::bbox::Handle;
use crate::{Overlay, ToolContext};
@@ -39,20 +41,67 @@ pub struct Targets {
points: Vec<(Point, Kind)>,
xs: Vec<(f64, Point, Kind)>,
ys: Vec<(f64, Point, Kind)>,
/// Path segments and their control bounds, which a point lands on ("path"): gathered only for
/// dragged anchors ([`Self::for_anchor_drag`]).
segments: Vec<(Rect, PathSeg)>,
}
/// How many anchors, and how many segments, the targets gather at most.
const BUDGET: usize = 20_000;
impl Targets {
pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option<Rect>) -> Self {
Self::collect_inner(doc, exclude, None, visible)
Self::collect_inner(doc, exclude, None, visible, false)
}
/// Targets for dragging artboard `index`: everything except that artboard and `exclude` (the
/// art that moves along with it).
pub fn for_artboard(doc: &Document, index: usize, exclude: &[NodeId]) -> Self {
Self::collect_inner(doc, exclude, Some(index), None)
Self::collect_inner(doc, exclude, Some(index), None, false)
}
fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option<usize>, visible: Option<Rect>) -> Self {
/// Targets for dragging the direct-selected anchors of `sel` (and its objects selected as a
/// whole): the other art, its path segments too, and the anchors and segments that stay put on
/// the paths being reshaped (their bounds move along, so they are no targets).
pub fn for_anchor_drag(doc: &Document, sel: &Selection) -> Self {
let mut t = Self::collect_inner(doc, &sel.objects, None, None, true);
let (mut anchors, mut segments) = (BUDGET, BUDGET);
for (id, set) in &sel.anchors {
let Some(path) = doc.node(*id).and_then(|n| n.path_data()) else { continue };
for (si, sp) in path.subpaths.iter().enumerate() {
let moving = |ai: usize| set.contains(&(si, ai));
for a in sp.anchors.iter().enumerate().filter(|(ai, _)| !moving(*ai)).map(|(_, a)| a.p).take(anchors) {
t.points.push((a, Kind::Anchor));
t.xs.push((a.x, a, Kind::Anchor));
t.ys.push((a.y, a, Kind::Anchor));
anchors -= 1;
}
t.push_segments(sp, moving, &mut segments);
}
}
t
}
/// The segments of `sp` that stay put while the anchors `moving` accepts move, as targets, at
/// most `budget` of them (counted down).
fn push_segments(&mut self, sp: &SubPath, moving: impl Fn(usize) -> bool, budget: &mut usize) {
let n = sp.anchors.len();
for seg in 0..sp.segment_count() {
if *budget == 0 {
return;
}
if moving(seg) || moving((seg + 1) % n) {
continue;
}
let c = sp.segment(seg);
let bounds = Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3);
// A straight one as a line, so the point lands on it exactly.
self.segments.push((bounds, if sp.segment_is_line(seg) { PathSeg::Line(Line::new(c.p0, c.p3)) } else { PathSeg::Cubic(c) }));
*budget -= 1;
}
}
fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option<usize>, visible: Option<Rect>, segments: bool) -> Self {
let mut t = Targets::default();
let excluded = |id: NodeId| exclude.iter().any(|e| doc.ancestry(id).is_some_and(|a| a.contains(e)));
let add_rect = |t: &mut Targets, r: Rect, kind: Kind| {
@@ -74,7 +123,7 @@ impl Targets {
add_rect(&mut t, doc.setup.bleed_rect(ab.rect), Kind::Bleed);
}
}
let mut budget = 20_000usize;
let (mut budget, mut segment_budget) = (BUDGET, BUDGET);
doc.walk(|n| {
if budget == 0 || n.is_container() || !n.visible || excluded(n.id) {
return;
@@ -91,6 +140,11 @@ impl Targets {
t.points.push((a.p, Kind::Anchor));
budget = budget.saturating_sub(1);
}
if segments {
for sp in &path.subpaths {
t.push_segments(sp, |_| false, &mut segment_budget);
}
}
}
add_rect(&mut t, b, Kind::Edge);
});
@@ -103,6 +157,7 @@ impl Targets {
self.points.retain(|(_, k)| *k == Kind::Anchor);
self.xs.clear();
self.ys.clear();
self.segments.clear();
if cx.guides {
for g in &cx.doc.guides {
let (lines, at) = if g.vertical { (&mut self.xs, Point::new(g.pos, 0.0)) } else { (&mut self.ys, Point::new(0.0, g.pos)) };
@@ -140,11 +195,15 @@ impl Targets {
t
}
/// Snap a single point. Returns the snapped point and guide overlays.
/// Snap a single point: onto the nearest anchor or centre, else onto the nearest segment, else
/// into line with targets. Returns the snapped point and guide overlays.
pub fn snap_point(&self, p: Point, tol: f64) -> (Point, Vec<Overlay>) {
if let Some((q, k)) = self.points.iter().filter(|(q, _)| q.distance(p) <= tol).min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p))) {
return (*q, vec![Overlay::Label { p: *q, text: k.label().into(), color: MAGENTA }]);
}
if let Some(q) = self.on_segment(p, tol) {
return (q, vec![Overlay::Label { p: q, text: Kind::Edge.label().into(), color: MAGENTA }]);
}
let mut out = p;
let mut ov = vec![];
if let Some((x, from, _)) =
@@ -165,6 +224,17 @@ impl Targets {
(out, ov)
}
/// The nearest point within `tol` of `p` on a target segment.
fn on_segment(&self, p: Point, tol: f64) -> Option<Point> {
self.segments
.iter()
.filter(|(b, _)| b.inflate(tol, tol).contains(p))
.map(|(_, c)| (c, c.nearest(p, 1e-6)))
.filter(|(_, n)| n.distance_sq <= tol * tol)
.min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq))
.map(|(c, n)| c.eval(n.t))
}
/// Snap a ruler guide at `v` (the x of a vertical one, the y of a horizontal one) into line
/// with the nearest edge, centre or anchor within `tol`: where it goes, and a label there.
pub fn snap_guide(&self, vertical: bool, v: f64, tol: f64) -> (f64, Vec<Overlay>) {
@@ -335,19 +405,44 @@ pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec<
/// Snap `p` to pixels or the grid when they are on, else to the smart guide `targets`, else (Snap
/// to Point) to their anchors and the ruler guides within the Snap to Point distance.
fn snap_with(cx: &ToolContext, p: Point, targets: impl FnOnce() -> Targets) -> (Point, Vec<Overlay>) {
if cx.snap_to_pixel {
return (Point::new(p.x.round(), p.y.round()), vec![]);
PointSnap::new(cx, targets).snap(cx, p)
}
/// Snapping for a dragged point, its targets gathered once (when the drag begins): what
/// [`snap_draw`] does for a point at a time.
pub struct PointSnap {
/// The smart guide targets, or with Smart Guides off those of Snap to Point, and how near (in
/// screen pixels) they pull; none with pixels or the grid snapping instead, or nothing on.
targets: Option<(Targets, f64)>,
}
impl PointSnap {
pub fn new(cx: &ToolContext, targets: impl FnOnce() -> Targets) -> Self {
let targets = if cx.snap_to_pixel || cx.snap_to_grid {
None
} else if cx.smart_guides {
Some((targets(), 5.0))
} else if cx.snap_to_point {
Some((targets().for_snap_to_point(cx), cx.snap_tolerance))
} else {
None
};
Self { targets }
}
if cx.snap_to_grid {
return (vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step()), vec![]);
/// Where `p` goes, and the guides showing why.
pub fn snap(&self, cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
if cx.snap_to_pixel {
return (Point::new(p.x.round(), p.y.round()), vec![]);
}
if cx.snap_to_grid {
return (vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step()), vec![]);
}
match &self.targets {
Some((t, tol)) => t.snap_point(p, cx.tol(*tol)),
None => (p, vec![]),
}
}
if cx.smart_guides {
return targets().snap_point(p, cx.tol(5.0));
}
if cx.snap_to_point {
return targets().for_snap_to_point(cx).snap_point(p, cx.tol(cx.snap_tolerance));
}
(p, vec![])
}
/// Where a dragged direction handle of anchor `ai` of subpath `si` of path `id` goes for the
@@ -453,4 +548,20 @@ mod tests {
assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 102.0));
assert!(ov.is_empty());
}
/// Dragging an anchor of a path: its other anchors and the segments not touching it stay
/// targets, but neither it nor the path's bounds (which move) are.
#[test]
fn anchor_drag_targets_leave_out_what_moves() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
s.anchors.insert(id, [(0, 0)].into_iter().collect());
let t = Targets::for_anchor_drag(&d, &s);
let anchors: Vec<Point> = t.points.iter().filter(|(_, k)| *k == Kind::Anchor).map(|(p, _)| *p).collect();
assert_eq!(anchors, [Point::new(200.0, 100.0), Point::new(200.0, 200.0), Point::new(100.0, 200.0)]);
assert_eq!(t.segments.len(), 2);
assert!(!t.points.iter().any(|(p, k)| *k == Kind::Center && *p == Point::new(150.0, 150.0)), "the rect's centre moves");
assert_eq!(t.snap_point(Point::new(101.0, 99.0), 4.0).0, Point::new(100.0, 100.0), "in line with its neighbours, not on itself");
}
}
+18
View File
@@ -20,6 +20,7 @@ pub mod guides;
pub mod meshblend;
pub mod meshedit;
pub mod params;
pub mod pathtype;
pub mod pen;
pub mod place;
pub mod printtiling;
@@ -409,6 +410,8 @@ pub enum Cursor {
BlendObject,
/// The Blend tool over an anchor point (the blend starts there): a crosshair with a target.
BlendAnchor,
/// Over a bracket of selected type on a path (a drag moves it): the arrow with a bracket.
PathBracket,
}
impl Cursor {
@@ -569,6 +572,21 @@ pub(crate) mod testutil {
(d, id)
}
/// [`doc_with_rect`] plus type on a 300 pt path from (100, 300) to the right, from 20 % on.
pub fn doc_with_path_type() -> (Document, NodeId) {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
let mut t = vectorcraft_doc::TextObject::point(Point::ZERO, "Path type", Default::default());
let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![
vectorcraft_geom::PathEl::MoveTo(Point::new(100.0, 300.0)),
vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)),
]));
t.kind = vectorcraft_doc::TextKind::OnPath { path, start: 0.2, end: None };
d.insert(Some(l), 1, Node::new(id, vectorcraft_doc::NodeKind::Text(Box::new(t)))).unwrap();
(d, id)
}
pub fn paint() -> PaintDefaults {
PaintDefaults::default()
}
+297
View File
@@ -0,0 +1,297 @@
//! Type on a path's brackets: selected type on a path shows a start, a centre and an end bracket
//! with the Selection and Direct Selection tools. Dragging the start or end bracket sets where the
//! type begins or ends along its path; dragging the centre bracket slides the type along the path
//! (its brackets with it) and, dragged across the path, flips the type to the other side
//! (Cmd/Ctrl held: it only slides). Each drag is one undo step of `type.pathOptions`. The tools
//! draw the brackets from the same geometry they hit-test.
use serde_json::{Map, Value, json};
use vectorcraft_doc::{NodeId, NodeKind, TextKind};
use vectorcraft_geom::kurbo::{Line, ParamCurveNearest};
use vectorcraft_geom::{Affine, ArcPath, Point, Vec2};
use crate::{Action, Overlay, PointerEvent, ToolContext};
/// How far a bracket reaches (screen px): up from the path on the type's side, down across it,
/// and the foot of the start and end brackets along the path, into the type's span.
const UP_PX: f64 = 14.0;
const DOWN_PX: f64 = 4.0;
const FOOT_PX: f64 = 4.0;
/// How far past the path (screen px) the centre bracket goes before the type flips.
const FLIP_PX: f64 = 6.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Bracket {
Start,
Centre,
End,
}
impl Bracket {
/// Hit-test order: the centre bracket first, then the start one (a closed path's span with
/// no end of its own has both ends at the start).
const ALL: [Bracket; 3] = [Bracket::Centre, Bracket::Start, Bracket::End];
}
/// The brackets of a selected type on a path.
#[derive(Clone, Debug)]
pub struct Brackets {
pub id: NodeId,
/// Text space → document.
xf: Affine,
/// The path in text space.
arc: ArcPath,
/// [`TextKind::path_span`].
span: (f64, f64),
/// The end bracket was placed (not the end of the path, or once round it).
end_set: bool,
}
impl Brackets {
/// The brackets of the selected type on a path the tools can edit (outside perspective).
pub fn of(cx: &ToolContext) -> Vec<Self> {
cx.selection.objects.iter().filter_map(|id| Self::of_node(cx, *id)).collect()
}
fn of_node(cx: &ToolContext, id: NodeId) -> Option<Self> {
let n = cx.doc.node(id).filter(|n| n.perspective.is_none() && cx.doc.is_editable(id))?;
let NodeKind::Text(t) = &n.kind else { return None };
let TextKind::OnPath { path, end, .. } = &t.kind else { return None };
let det = t.xf.determinant();
let arc = ArcPath::new(path);
if !det.is_finite() || det.abs() < 1e-12 || arc.is_empty() {
return None;
}
Some(Self { id, xf: t.xf, arc, span: t.kind.path_span()?, end_set: end.is_some() })
}
/// Where bracket `b` stands, as a fraction of the path's length (past 1 round a closed path).
fn at(&self, b: Bracket) -> f64 {
match b {
Bracket::Start => self.span.0,
Bracket::Centre => (self.span.0 + self.span.1) * 0.5,
Bracket::End => self.span.1,
}
}
/// The point at fraction `f` of the path, the path's direction there and the side the type
/// stands on, in document space.
fn frame(&self, f: f64) -> (Point, Vec2, Vec2) {
let (p, dir) = self.arc.at(f * self.arc.len());
let unit = |v: Vec2| {
let w = self.xf * v.to_point() - self.xf * Point::ZERO;
w / w.hypot().max(1e-12)
};
// Glyphs stand on the left of the path's direction (y down): their top faces (dir.y, -dir.x).
(self.xf * p, unit(dir), unit(Vec2::new(dir.y, -dir.x)))
}
/// The lines that draw bracket `b` at `zoom` (screen px per point).
fn lines(&self, b: Bracket, zoom: f64) -> Vec<(Point, Point)> {
let px = 1.0 / zoom.max(1e-9);
let (p, dir, up) = self.frame(self.at(b));
let top = p + up * UP_PX * px;
let stem = (p - up * DOWN_PX * px, top);
match b {
Bracket::Centre => vec![stem],
Bracket::Start => vec![stem, (top, top + dir * FOOT_PX * px)],
Bracket::End => vec![stem, (top, top - dir * FOOT_PX * px)],
}
}
/// The bracket within `tol` (document units) of `p`.
fn hit(&self, p: Point, zoom: f64, tol: f64) -> Option<Bracket> {
Bracket::ALL.into_iter().find(|b| self.lines(*b, zoom).iter().any(|(a, c)| Line::new(*a, *c).nearest(p, 1e-9).distance_sq <= tol * tol))
}
/// The fraction of the path's length nearest document point `p`.
fn fraction_at(&self, p: Point) -> Option<f64> {
self.arc.fraction_at(self.xf.inverse() * p)
}
}
/// The selected type on a path's bracket under `p`, if any.
fn bracket_at(cx: &ToolContext, p: Point) -> Option<(Brackets, Bracket)> {
Brackets::of(cx).into_iter().find_map(|b| {
let which = b.hit(p, cx.zoom, cx.tol(5.0))?;
Some((b, which))
})
}
/// Is `p` over a bracket of the selected type on a path?
pub fn over_bracket(cx: &ToolContext, p: Point) -> bool {
bracket_at(cx, p).is_some()
}
/// The brackets of the selected type on a path, in their layer's colour.
pub fn overlays(cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
for b in Brackets::of(cx) {
let color = cx.doc.layer_color(b.id);
for which in Bracket::ALL {
out.extend(b.lines(which, cx.zoom).into_iter().map(|(a, b)| Overlay::Line { a, b, color, dashed: false }));
}
}
out
}
/// Dragging a bracket of type on a path.
#[derive(Clone, Debug)]
pub struct BracketDrag {
brackets: Brackets,
which: Bracket,
start: Point,
/// Where the press was along the path (a fraction of its length).
grab: f64,
began: bool,
}
impl BracketDrag {
/// Start a drag when the press `ev` is on a bracket of the selected type on a path.
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
let (brackets, which) = bracket_at(cx, ev.pos)?;
let grab = brackets.fraction_at(ev.pos)?;
Some(Self { brackets, which, start: ev.pos, grab, began: false })
}
pub fn drag(&mut self, cx: &ToolContext, p: Point, cmd: bool) -> Vec<Action> {
let mut out = vec![];
if !self.began {
if p.distance(self.start) < cx.tol(3.0) {
return out;
}
self.began = true;
out.push(Action::Begin("Move Type on a Path".into()));
}
let Some(u) = self.brackets.fraction_at(p) else { return out };
out.push(Action::Preview("type.pathOptions".into(), self.params(cx, p, u, cmd)));
out
}
/// `type.pathOptions` params for the pointer at `p`, `u` along the path.
fn params(&self, cx: &ToolContext, p: Point, u: f64, cmd: bool) -> Value {
let b = &self.brackets;
let closed = b.arc.is_closed();
let (s, e) = b.span;
let mut m = Map::new();
m.insert("ids".into(), json!([b.id.0]));
match self.which {
// Open paths keep the start before the end.
Bracket::Start => {
m.insert("start".into(), json!(if closed { u } else { u.min(e) }));
}
Bracket::End => {
m.insert("end".into(), json!(if closed { u } else { u.max(s) }));
}
Bracket::Centre => {
let mut d = u - self.grab;
if closed {
// The short way round.
d -= d.round();
}
let place = |x: f64| if closed { x.rem_euclid(1.0) } else { x.clamp(0.0, 1.0) };
let start = place(s + d);
m.insert("start".into(), json!(start));
if b.end_set {
m.insert("end".into(), json!(if closed { place(e + d) } else { place(e + d).max(start) }));
}
// Across the path: the type flips to the other side.
let (q, _, up) = b.frame(u);
if !cmd && (p - q).dot(up) < -cx.tol(FLIP_PX) {
m.insert("flip".into(), json!(true));
}
}
}
Value::Object(m)
}
pub fn finish(self) -> Vec<Action> {
if self.began { vec![Action::Commit] } else { vec![] }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use crate::{Mods, PointerKind};
use vectorcraft_doc::Selection;
fn selected(id: NodeId) -> Selection {
let mut s = Selection::default();
s.add(id);
s
}
fn preview(a: &[Action]) -> &Value {
let Some(Action::Preview(c, v)) = a.last() else { panic!("no preview: {a:?}") };
assert_eq!(c, "type.pathOptions");
v
}
fn drag(cx: &ToolContext, from: Point, to: Point, mods: Mods) -> Vec<Action> {
let mut d = BracketDrag::hit(cx, &PointerEvent::new(PointerKind::Down, from.x, from.y).with_mods(mods)).expect("a bracket");
let a = d.drag(cx, to, mods.cmd);
assert_eq!(a.first(), Some(&Action::Begin("Move Type on a Path".into())));
assert_eq!(d.finish(), vec![Action::Commit]);
a
}
#[test]
fn brackets_stand_at_the_start_the_middle_and_the_end_of_the_span() {
// A 300 pt path from (100, 300) with the type from 20 % on.
let (d, id) = doc_with_path_type();
let s = selected(id);
let p = paint();
let cx = cx(&d, &s, &p);
let b = Brackets::of(&cx).pop().unwrap();
assert_eq!(b.hit(Point::new(160.0, 290.0), 1.0, 2.0), Some(Bracket::Start));
assert_eq!(b.hit(Point::new(280.0, 290.0), 1.0, 2.0), Some(Bracket::Centre));
assert_eq!(b.hit(Point::new(400.0, 290.0), 1.0, 2.0), Some(Bracket::End));
assert_eq!(b.hit(Point::new(220.0, 290.0), 1.0, 2.0), None);
// Drawn over the type's side of the path, with feet pointing into the span.
let o = overlays(&cx);
assert_eq!(o.len(), 5);
assert!(o.contains(&Overlay::Line { a: Point::new(160.0, 286.0), b: Point::new(164.0, 286.0), color: d.layer_color(id), dashed: false }));
// Nothing for unselected or other type.
assert!(Brackets::of(&crate::testutil::cx(&d, &Selection::default(), &p)).is_empty());
let (d2, area) = doc_with_area_type();
let s2 = selected(area);
assert!(Brackets::of(&crate::testutil::cx(&d2, &s2, &p)).is_empty());
}
#[test]
fn dragging_the_centre_bracket_slides_the_type_and_across_the_path_flips_it() {
let (d, id) = doc_with_path_type();
let s = selected(id);
let p = paint();
let cx = cx(&d, &s, &p);
let grab = Point::new(280.0, 295.0);
// 60 pt along: 20 % further, the end (the end of the path) left alone.
let v = preview(&drag(&cx, grab, Point::new(340.0, 295.0), Mods::default())).clone();
assert!((v["start"].as_f64().unwrap() - 0.4).abs() < 1e-6, "{v}");
assert!(v.get("end").is_none() && v.get("flip").is_none(), "{v}");
assert_eq!(v["ids"], json!([id.0]));
// Below the path: flipped as well.
let v = preview(&drag(&cx, grab, Point::new(250.0, 320.0), Mods::default())).clone();
assert!((v["start"].as_f64().unwrap() - 0.1).abs() < 1e-6 && v["flip"] == true, "{v}");
// Cmd/Ctrl held: it only slides.
let v = preview(&drag(&cx, grab, Point::new(250.0, 320.0), Mods { cmd: true, ..Mods::default() })).clone();
assert!(v.get("flip").is_none(), "{v}");
}
#[test]
fn dragging_the_start_and_end_brackets_moves_them() {
let (d, id) = doc_with_path_type();
let s = selected(id);
let p = paint();
let cx = cx(&d, &s, &p);
let v = preview(&drag(&cx, Point::new(160.0, 295.0), Point::new(130.0, 280.0), Mods::default())).clone();
assert!((v["start"].as_f64().unwrap() - 0.1).abs() < 1e-6 && v.get("end").is_none(), "{v}");
let v = preview(&drag(&cx, Point::new(400.0, 295.0), Point::new(340.0, 310.0), Mods::default())).clone();
assert!((v["end"].as_f64().unwrap() - 0.8).abs() < 1e-6 && v.get("start").is_none(), "{v}");
// The end stops at the start.
let v = preview(&drag(&cx, Point::new(400.0, 295.0), Point::new(110.0, 300.0), Mods::default())).clone();
assert!((v["end"].as_f64().unwrap() - 0.2).abs() < 1e-6, "{v}");
}
}
+77 -35
View File
@@ -4,7 +4,8 @@
//! drag a live rectangle's corner widget to round its corners (Alt-click cycles their kind,
//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To
//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
//! click or drag a ruler guide ([`crate::rulerguide`]).
//! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path
//! ([`crate::pathtype`]).
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of
//! scaling its type: the text reflows at its size.
@@ -12,10 +13,12 @@
use serde_json::{Value, json};
use vectorcraft_doc::hit::{hit_test, marquee, objects_at};
use vectorcraft_doc::{NodeId, OrientedBox};
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
use crate::corners::{self, CornerDrag, over_widget};
use crate::guides::Targets;
use crate::pathtype::{self, BracketDrag, over_bracket};
use crate::rulerguide::GuideEdit;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids};
@@ -48,6 +51,8 @@ enum State {
},
/// Dragging a Live Corners widget.
Corner(CornerDrag),
/// Dragging a bracket of type on a path.
Bracket(BracketDrag),
}
#[derive(Default)]
@@ -55,14 +60,54 @@ pub struct SelectionTool {
state: State,
measure: Option<(Point, String)>,
guides: Vec<Overlay>,
targets: Option<crate::guides::Targets>,
/// View → Snap to Point (Smart Guides off): the anchors and ruler guides the grabbed point
/// lands on while moving.
points: Option<crate::guides::Targets>,
start_bounds: Option<Rect>,
/// What a bounding-box handle being dragged snaps to.
targets: Option<Targets>,
moving: Option<MoveSnap>,
guide: GuideEdit,
}
/// Snapping for the selection moved as a whole, its targets gathered when the move begins: Smart
/// Guides line its bounds up with the other art; with them off, View → Snap to Point lands the
/// point it was grabbed by on an anchor or a ruler guide; Snap to Pixel puts its top-left on whole
/// pixels.
pub(crate) struct MoveSnap {
bounds: Option<Rect>,
targets: Option<Targets>,
points: Option<Targets>,
}
impl MoveSnap {
pub(crate) fn new(cx: &ToolContext) -> Self {
Self {
bounds: selection_bounds(cx),
targets: cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None)),
points: Targets::snap_to_point(cx, &cx.selection.objects),
}
}
/// The move by `d` of the selection grabbed at `start`, snapped, and its guides.
pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2) -> (Vec2, Vec<Overlay>) {
let mut guides = vec![];
if let Some(t) = &self.points {
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
d = q - start;
guides = ov;
}
if let (Some(t), Some(b)) = (&self.targets, self.bounds) {
let (adj, ov) = t.snap_rect(b + d, cx.tol(5.0));
d += adj;
guides = ov;
}
if cx.snap_to_pixel
&& let Some(b) = self.bounds
{
d = Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0);
guides.clear();
}
(d, guides)
}
}
pub fn matrix_json(a: Affine) -> Value {
let c = a.as_coeffs();
json!([c[0], c[1], c[2], c[3], c[4], c[5]])
@@ -140,11 +185,16 @@ impl Tool for SelectionTool {
vec![]
}
(PointerKind::Down, _) => {
// 1. Live Corners widgets, then the bounding-box handles of the current selection.
// 1. Live Corners widgets and type on a path's brackets, then the bounding-box
// handles of the current selection.
if let Some(c) = CornerDrag::hit(cx, ev) {
self.state = State::Corner(c);
return vec![];
}
if let Some(b) = BracketDrag::hit(cx, ev) {
self.state = State::Bracket(b);
return vec![];
}
if cx.show_bbox
&& let Some(bx) = selection_box(cx)
{
@@ -155,7 +205,7 @@ impl Tool for SelectionTool {
// Only area type: the step resizes type areas; anything else scales.
let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" };
// Smart Guides align what the handle moves with the other objects.
self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None));
self.targets = cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None));
self.state = State::Scaling { handle, bx, areas };
return vec![Action::Begin(label.into())];
}
@@ -211,31 +261,11 @@ impl Tool for SelectionTool {
return out;
}
out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() }));
self.moving = Some(MoveSnap::new(cx));
}
let mut d = move_delta(start, p, m.shift);
if !began {
self.start_bounds = selection_bounds(cx);
self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None));
self.points = crate::guides::Targets::snap_to_point(cx, &cx.selection.objects);
}
self.guides.clear();
// Snap to Point: the point the selection was grabbed by lands on an anchor or guide.
if let Some(t) = &self.points {
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
d = q - start;
self.guides = ov;
}
if let (Some(t), Some(b)) = (&self.targets, self.start_bounds) {
let (adj, ov) = t.snap_rect(b + d, cx.tol(5.0));
d += adj;
self.guides = ov;
}
// Snap to Pixel: the moved selection's top-left lands on whole pixels.
if cx.snap_to_pixel
&& let Some(b) = self.start_bounds
{
d = vectorcraft_geom::Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0);
self.guides.clear();
if let Some(snap) = &self.moving {
(d, self.guides) = snap.snap(cx, start, d);
}
self.state = State::Moving { start, began: true, deselect: None };
self.measure = Some((p, cx.offset_label(d.x, d.y)));
@@ -275,12 +305,20 @@ impl Tool for SelectionTool {
self.state = State::Idle;
c.finish()
}
(PointerKind::Drag, State::Bracket(mut b)) => {
let out = b.drag(cx, p, m.cmd);
self.state = State::Bracket(b);
out
}
(PointerKind::Up, State::Bracket(b)) => {
self.state = State::Idle;
b.finish()
}
(PointerKind::Up, State::Moving { began, deselect, .. }) => {
self.state = State::Idle;
self.measure = None;
self.guides.clear();
self.targets = None;
self.points = None;
self.moving = None;
match (began, deselect) {
(true, _) => vec![Action::Commit],
(false, Some(id)) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
@@ -312,7 +350,7 @@ impl Tool for SelectionTool {
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = vec![];
let mut o = pathtype::overlays(cx);
match &self.state {
State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
State::Corner(c) => o.extend(c.overlays(cx)),
@@ -332,6 +370,7 @@ impl Tool for SelectionTool {
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
State::Moving { began: true, .. } => return Cursor::Arrow,
State::Corner(_) => return Cursor::CornerRadius,
State::Bracket(_) => return Cursor::PathBracket,
_ => {}
}
if self.guide.busy() {
@@ -340,6 +379,9 @@ impl Tool for SelectionTool {
if over_widget(cx, p) {
return Cursor::CornerRadius;
}
if over_bracket(cx, p) {
return Cursor::PathBracket;
}
if cx.show_bbox
&& let Some(bx) = selection_box(cx)
{
+9 -7
View File
@@ -401,7 +401,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
fn cursor_icon(c: Cursor) -> egui::CursorIcon {
use egui::CursorIcon as C;
match c {
Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius => C::Default,
Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius | Cursor::PathBracket => C::Default,
Cursor::Move => C::Move,
Cursor::Crosshair => C::Crosshair,
Cursor::ResizeH => C::ResizeHorizontal,
@@ -972,10 +972,11 @@ fn node_outline(n: &Node) -> BezPath {
let mut bp = BezPath::new();
walk_drawn(n, &mut |c| match &c.kind {
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
// Area type shows its frame: the type area Direct Selection reshapes.
NodeKind::Text(t) if c.perspective.is_none() && matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => {
if let Some(frame) = t.area_frame() {
bp.extend(frame.to_bezpath());
// Area type shows its frame: the type area Direct Selection reshapes; type on a path its
// path, which the selection tools' brackets sit on.
NodeKind::Text(t) if c.perspective.is_none() && !matches!(t.kind, vectorcraft_doc::TextKind::Point) => {
if let Some(path) = t.area_frame().or_else(|| t.type_path()) {
bp.extend(path.to_bezpath());
}
}
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
@@ -1344,9 +1345,10 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
{
anchor_square(p, xf.to_screen(b.center()), color, true, anchor(false));
}
// Text: baseline marker (area type shows its frame instead, which may not be a rectangle).
// Point type: baseline marker (area type shows its frame instead, which may not be a
// rectangle, and type on a path its path).
if let NodeKind::Text(tx) = &n.kind
&& !matches!(tx.kind, vectorcraft_doc::TextKind::Area { .. })
&& matches!(tx.kind, vectorcraft_doc::TextKind::Point)
{
let o = xf.to_screen(tx.xf * Point::ZERO);
let b = n.geometric_bounds().unwrap_or_default();
+1 -1
View File
@@ -217,7 +217,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
if k == "height" {
crate::panels::transform::constrain_link(app, ui);
}
ui.label(egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 80.0) {
app.run("object.setBounds", json!({k: nv, "reference": 4, "proportional": link})).ok();
}
+16
View File
@@ -111,6 +111,21 @@ fn corner_radius(p: &Painter, o: Pos2) {
p.add(Shape::line(pts, Stroke::new(1.2, INK)));
}
/// Over a bracket of type on a path: the arrow with a bracket (a stem standing on a baseline,
/// with a foot) below right.
fn path_bracket(p: &Painter, o: Pos2) {
arrow(p, o, false);
let b = o + vec2(13.0, 13.0);
let stem = [b + vec2(3.0, 0.0), b + vec2(3.0, 10.0)];
let foot = [b + vec2(3.0, 0.0), b + vec2(7.0, 0.0)];
let base = [b + vec2(0.0, 8.0), b + vec2(10.0, 8.0)];
for (w, c) in [(3.0, HALO), (1.2, INK)] {
for l in [stem, foot, base] {
p.line_segment(l, Stroke::new(w, c));
}
}
}
/// The gradient annotator's stop cursors: the arrow with a plus (add a stop) or minus (delete it)
/// badge.
fn stop_badge(p: &Painter, o: Pos2, add: bool) {
@@ -221,6 +236,7 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
Cursor::WidthAdd => width(painter, p, "+"),
Cursor::WidthPoint => width(painter, p, "point"),
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(painter, p, c),
Cursor::PathBracket => path_bracket(painter, p),
_ => return false,
}
let _ = pos2;
+2 -1
View File
@@ -13,7 +13,8 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
.collapsible(false)
.resizable(false)
.open(&mut open)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(18)))
.show(ctx, |ui| {
// Tabs: About · Contributors · Models (the credits are compiled in, see `credits`).
+16 -2
View File
@@ -10,8 +10,9 @@ use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| tl!(&d.str("__label")).to_string(),
body: |app, ui, d| {
let lengths = lengths(&d.str("__command"));
form::param_fields(ui, d, &|k| lengths.contains(&k), app.session.general_unit());
let command = d.str("__command");
let lengths = lengths(&command);
form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), app.session.general_unit());
false
},
confirm,
@@ -27,10 +28,23 @@ fn lengths(command: &str) -> &'static [&'static str] {
"perspective.grid.set" => &["cell", "distance"],
"object.repeat.options" => &["radius", "hSpacing", "vSpacing"],
"text.areaOptions" => &["width", "height", "gutter", "inset", "firstBaselineMin"],
"type.pathOptions" => &["spacing"],
_ => &[],
}
}
/// The parameters of the commands this dialog edits that pick one of some values.
fn choices(command: &str, key: &str) -> Option<form::Choices> {
match (command, key) {
("type.pathOptions", "effect") => Some(crate::menus::PATH_EFFECTS),
("type.pathOptions", "alignToPath") => Some(PATH_ALIGN),
_ => None,
}
}
/// Type on a Path Options › Align to Path.
const PATH_ALIGN: form::Choices = &[("Ascender", "ascender"), ("Descender", "descender"), ("Center", "center"), ("Baseline", "baseline")];
/// Closes before running, so a dialog the command opens stays open.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let cmd = d.str("__command");
+2 -2
View File
@@ -180,7 +180,7 @@ fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
}
ui.end_row();
for (key, label) in [("superscript", tl!("Superscript:")), ("subscript", tl!("Subscript:"))] {
ui.label(egui::RichText::new(label).color(t.text));
widgets::field_label(ui, egui::RichText::new(label).color(t.text));
for part in ["size", "position"] {
let v = d.fields.get(key).and_then(|m| m.get(part)).and_then(Value::as_f64).unwrap_or(0.0);
if let Some(n) = widgets::plain_field(ui, (key, part), v, "%", 1, 70.0) {
@@ -191,7 +191,7 @@ fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
}
ui.end_row();
}
ui.label(egui::RichText::new(tl!("Small Caps:")).color(t.text));
widgets::field_label(ui, egui::RichText::new(tl!("Small Caps:")).color(t.text));
if let Some(n) = widgets::plain_field(ui, "smallCaps", d.f64("smallCapsSize", 70.0), "%", 1, 70.0) {
d.fields.insert("smallCapsSize".into(), json!(n));
}
+1 -1
View File
@@ -131,7 +131,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let changed = match vectorcraft_plugins::effect::installed(&id) {
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
None => form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), app.session.general_unit()),
None => form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|_| None, app.session.general_unit()),
};
ui.add_space(6.0);
let mut pv = d.bool("preview");
+89 -21
View File
@@ -17,16 +17,20 @@ pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
/// [`field`] `width` points wide.
pub(super) fn field_w(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, width: f32) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
row_label(ui, label);
text(ui, d, key, width);
ui.end_row();
}
/// A labelled [`length`] field (one grid row).
pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, unit: Unit) {
/// The label cell of a grid row.
fn row_label(ui: &mut egui::Ui, label: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
}
/// A labelled [`length`] field (one grid row).
pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, unit: Unit) {
row_label(ui, label);
length(ui, d, key, unit, FIELD_W);
ui.end_row();
}
@@ -57,6 +61,30 @@ fn lengths(kind: &str) -> &'static [&'static str] {
}
}
/// The fields of the form dialogs ([`grid`]) that come first, in this order (the others follow by
/// name).
fn order(kind: &str) -> &'static [&'static str] {
match kind {
"offsetPath" => &["offset", "joins", "miterLimit"],
"splitIntoGrid" => &["rows", "columns", "gutter"],
_ => &[],
}
}
/// The choices of a form dialog's ([`grid`]) dropdown field, as (value, label in the UI
/// language); empty for a field typed as text.
fn choices(kind: &str, key: &str) -> Vec<(&'static str, &'static str)> {
// The axes in the "axis" context: some languages read the plain Horizontal and Vertical as
// type orientations.
let axis = |value, label| (value, crate::i18n::tr_ctx(crate::i18n::current(), "axis", label));
match (kind, key) {
("offsetPath", "joins") => vec![("miter", tl!("Miter")), ("round", tl!("Round")), ("bevel", tl!("Bevel"))],
("average", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical"), axis("both", "Both")],
("reflect" | "shear", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical")],
_ => vec![],
}
}
/// A text field `width` wide bound to `d.fields[key]`. Returns true when it changed.
pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> bool {
text_edit(ui, d, key, width).changed()
@@ -111,30 +139,49 @@ pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
/// A dropdown row bound to the string `d.fields[key]`: `label` in a column `widths.0` wide, the
/// dropdown `widths.1` wide; `options` are (value, label).
pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, widths: (f32, f32), options: &[(&str, &str)]) {
crate::widgets::label_row(ui, label, widths.0, |ui| dropdown(ui, d, key, widths.1, options, false));
}
/// A dropdown `width` wide bound to the string `d.fields[key]`; `options` are (value, label), the
/// labels shown as they are when `translated` (else the dropdown translates them). A value that
/// isn't among them shows as it is.
fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, options: &[(&str, &str)], translated: bool) {
let cur = d.str(key);
let shown = options.iter().find(|(v, _)| v.eq_ignore_ascii_case(&cur)).map_or(cur.as_str(), |(_, l)| l);
let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
crate::widgets::label_row(ui, label, widths.0, |ui| {
if let Some((value, _)) = crate::widgets::dropdown(ui, key, shown, &labels, widths.1).and_then(|i| options.get(i)) {
d.fields.insert(key.into(), json!(value));
}
});
let chosen = if translated {
crate::widgets::dropdown_names(ui, key, shown, &labels, width)
} else {
crate::widgets::dropdown(ui, key, shown, &labels, width)
};
if let Some((value, _)) = chosen.and_then(|i| options.get(i)) {
d.fields.insert(key.into(), json!(value));
}
}
/// The generic dialog body: a field per non-boolean value (positions and indices hidden), the
/// distances in `unit`.
/// The generic dialog body: a field per non-boolean value (positions, indices and UI-only keys
/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns.
pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
let lengths = lengths(&d.kind);
let (lengths, first) = (lengths(&d.kind), order(&d.kind));
let mut keys: Vec<String> = d
.fields
.iter()
.filter(|(k, v)| !matches!(k.as_str(), "x" | "y" | "origin" | "index") && !k.starts_with("__") && !v.is_boolean())
.map(|(k, _)| k.clone())
.collect();
// Stable: the fields `order` doesn't name keep their order after the ones it does.
keys.sort_by_key(|k| first.iter().position(|f| f == k).unwrap_or(first.len()));
egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
let keys: Vec<(String, Value)> = d.fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
for (k, v) in keys {
if k == "x" || k == "y" || k == "origin" || k == "index" || v.is_boolean() {
continue;
}
for k in keys {
let options = choices(&d.kind, &k);
if lengths.contains(&k.as_str()) {
length_field(ui, d, &k, &humanized(&k), unit);
} else {
} else if options.is_empty() {
field(ui, d, &k, &humanized(&k));
} else {
row_label(ui, &humanized(&k));
dropdown(ui, d, &k, FIELD_W, &options, true);
ui.end_row();
}
}
});
@@ -145,10 +192,19 @@ pub(super) fn params(d: &Dialog) -> Value {
Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect())
}
/// The (label, value) choices of a parameter that picks one of some values.
pub(super) type Choices = &'static [(&'static str, &'static str)];
/// Generic editor for command/effect parameters: numbers, booleans, strings and colours; the
/// parameters `is_length` names are distances shown and typed in `unit`. Returns true when a
/// value changed.
pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog, is_length: &dyn Fn(&str) -> bool, unit: Unit) -> bool {
/// parameters `is_length` names are distances shown and typed in `unit`, those `choices` gives
/// choices for are dropdowns. Returns true when a value changed.
pub(super) fn param_fields(
ui: &mut egui::Ui,
d: &mut Dialog,
is_length: &dyn Fn(&str) -> bool,
choices: &dyn Fn(&str) -> Option<Choices>,
unit: Unit,
) -> bool {
let t = Tokens::get(ui.ctx());
let mut changed = false;
egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
@@ -169,6 +225,17 @@ pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog, is_length: &dyn Fn
ui.end_row();
continue;
}
if let Some(options) = choices(&k) {
let cur = v.as_str().unwrap_or_default();
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| *l);
let labels: Vec<&str> = options.iter().map(|(l, _)| *l).collect();
if let Some((_, value)) = crate::widgets::dropdown(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
d.fields.insert(k, json!(value));
changed = true;
}
ui.end_row();
continue;
}
match v {
Value::Number(n) => {
let mut x = n.as_f64().unwrap_or(0.0);
@@ -271,6 +338,7 @@ pub(super) fn humanize(k: &str) -> String {
"Radius1" => "Radius 1".into(),
"Radius2" => "Radius 2".into(),
"Include Cmy Blacks" => "Include Blacks with CMY:".into(),
"Align To Path" => "Align to Path:".into(),
_ => format!("{s}:"),
}
}
+39 -50
View File
@@ -40,6 +40,7 @@ pub mod layer_options;
pub mod layers_panel_options;
pub mod liquify;
pub mod missing_links;
pub(crate) mod modal;
pub mod new_color_group;
mod new_document;
pub mod new_swatch;
@@ -99,7 +100,6 @@ pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset};
pub use tools::open_tool_dialog;
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets};
type DialogResult = Result<Value, String>;
@@ -342,62 +342,48 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if let Some(window) = spec.window {
return window(app, ctx);
}
let t = Tokens::get(ctx);
let mut ok = false;
let mut cancel = false;
let mut discard = false;
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
// Modal, but the canvas isn't dimmed so previews stay readable (as in the reference app).
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
let heading = (spec.heading)(&d);
egui::Window::new(heading.as_str())
// One window per kind, so a dialog never inherits another dialog's size.
.id(egui::Id::new(("dialog", d.kind.as_str())))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(MARGIN)))
.show(ctx, |ui| {
// Never wider than the window (a large UI scale in a small window): the text wraps.
let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP);
ui.set_min_width(spec.min_width.min(room));
ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room)));
ui.label(egui::RichText::new(heading.as_str()).font(theme::semibold(16.0)).color(t.text));
ui.add_space(12.0);
// Just opened: its first field takes the keyboard focus, as in the reference app (type a
// value, press Enter). Only the body's fields can take it.
if focus_first {
ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true));
modal::show(ctx, &heading, egui::Id::new(("dialog", d.kind.as_str())), -40.0, MARGIN, |ui| {
// Never wider than the window (a large UI scale in a small window): the text wraps.
let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP);
ui.set_min_width(spec.min_width.min(room));
ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room)));
modal::heading(ui, &heading);
ui.add_space(12.0);
// Just opened: its first field takes the keyboard focus, as in the reference app (type a
// value, press Enter). Only the body's fields can take it.
if focus_first {
ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true));
}
cancel = (spec.body)(app, ui, &mut d);
// Taken (the flag is gone): done. Still there (the window's measuring frame): next frame.
if focus_first && ui.data_mut(|m| m.remove_temp::<bool>(widgets::dialog_focus_flag())).is_none() {
ui.data_mut(|m| m.remove::<bool>(focus_id));
}
ui.add_space(16.0);
// The button row is as wide as the fields above it and as tall as the buttons: a
// right-to-left layout would otherwise take all the room left in the window, so the
// window could never shrink to its content.
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
&& widgets::primary_button(ui, label).clicked()
{
ok = true;
}
cancel = (spec.body)(app, ui, &mut d);
// Taken (the flag is gone): done. Still there (the window's measuring frame): next frame.
if focus_first && ui.data_mut(|m| m.remove_temp::<bool>(widgets::dialog_focus_flag())).is_none() {
ui.data_mut(|m| m.remove::<bool>(focus_id));
ui.add_space(8.0);
if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() {
cancel = true;
}
if let Some(label) = spec.discard {
ui.add_space(28.0);
discard = widgets::secondary_button(ui, label).clicked();
}
ui.add_space(16.0);
// The button row is as wide as the fields above it and as tall as the buttons: a
// right-to-left layout would otherwise take all the room left in the window, so the
// window could never shrink to its content.
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
&& widgets::primary_button(ui, label).clicked()
{
ok = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() {
cancel = true;
}
if let Some(label) = spec.discard {
ui.add_space(28.0);
discard = widgets::secondary_button(ui, label).clicked();
}
});
});
});
if spec.ok.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
ok = true;
}
@@ -482,3 +468,6 @@ mod tests_scale;
#[cfg(test)]
mod tests_perspective;
#[cfg(test)]
mod tests_modal;
+100
View File
@@ -0,0 +1,100 @@
//! The modal dialog window: a backdrop that keeps the rest of the app from taking clicks, and a
//! window with no title bar that opens centred and moves by dragging its heading (the band from its
//! top edge down to the heading), as a title bar would. Where it was moved to stays, per window, for
//! the session, and the window never leaves the screen.
use egui::{Id, Rect, Vec2};
use crate::theme::{self, Tokens};
/// Show the modal dialog window `id` (`title` is its accessible name), `nudge` points below the
/// screen's centre until it is moved, with `margin` inside its frame. `add` draws the contents and
/// should start with [`heading`] (or [`drag_band`]) so the window can be moved.
pub(crate) fn show<R>(ctx: &egui::Context, title: &str, id: Id, nudge: f32, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option<R> {
let t = Tokens::get(ctx);
egui::Area::new(Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::Pos2::ZERO).show(ctx, |ui| {
// Modal, but the canvas isn't dimmed so previews stay readable (as in the reference app).
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
egui::Window::new(title)
.id(id)
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, nudge))
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin)))
.show(ctx, add)
.and_then(|r| r.inner)
}
/// The dialog's heading, which also moves the window ([`drag_band`]).
pub(crate) fn heading(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
// Not selectable: a drag on it moves the window instead of selecting its text.
let r = ui.add(egui::Label::new(egui::RichText::new(text).font(theme::semibold(16.0)).color(t.text)).selectable(false));
drag_band(ui, r.rect.bottom());
}
/// Make the band of the dialog window [`show`] draws `ui` in, from its top edge down to `bottom`, a
/// handle that moves the window. Widgets added later on the band stay clickable (they sit above it).
pub(crate) fn drag_band(ui: &mut egui::Ui, bottom: f32) {
let id = ui.layer_id().id;
// Last frame's rectangle: none while the window measures itself the first time it shows.
let Some(outer) = ui.ctx().memory(|m| m.area_rect(id)) else { return };
let band = Rect::from_x_y_ranges(outer.x_range(), outer.top()..=bottom.max(outer.top()));
let r = ui.interact(band, handle(id), egui::Sense::drag());
if r.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
}
/// The drag handle of window `id`.
fn handle(id: Id) -> Id {
id.with("modal-drag")
}
/// Where window `id` sits, from the screen's centre: `nudge` down until it is first moved. While
/// its [`drag_band`] is dragged, where it sat when the drag began plus how far the pointer has moved
/// since the press, kept on screen (applied before the window places itself, so it follows the
/// pointer without a frame's lag).
fn offset(ctx: &egui::Context, id: Id, nudge: f32) -> Vec2 {
let (key, start_key) = (id.with("modal-offset"), id.with("modal-drag-start"));
let offset = ctx.data(|m| m.get_temp::<Vec2>(key)).unwrap_or(egui::vec2(0.0, nudge));
let dragged = ctx.read_response(handle(id)).is_some_and(|r| r.dragged());
let moved = ctx.input(|i| Some(i.pointer.latest_pos()? - i.pointer.press_origin()?));
let (true, Some(moved), Some(rect)) = (dragged, moved, ctx.memory(|m| m.area_rect(id))) else {
ctx.data_mut(|m| m.remove::<Vec2>(start_key));
return offset;
};
let start = ctx.data_mut(|m| *m.get_temp_mut_or(start_key, offset));
let screen = ctx.content_rect();
let to = on_screen(Rect::from_center_size(screen.center() + start + moved, rect.size()), screen).center() - screen.center();
ctx.data_mut(|m| m.insert_temp(key, to));
to
}
/// `r` moved the least to lie inside `screen` (its top left inside when it is larger).
fn on_screen(r: Rect, screen: Rect) -> Rect {
let x = r.left().min(screen.right() - r.width()).max(screen.left());
let y = r.top().min(screen.bottom() - r.height()).max(screen.top());
Rect::from_min_size(egui::pos2(x, y), r.size())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_moved_window_stays_on_screen() {
let screen = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
let r = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(egui::pos2(x, y), egui::vec2(w, h));
assert_eq!(on_screen(r(100.0, 100.0, 200.0, 100.0), screen), r(100.0, 100.0, 200.0, 100.0));
assert_eq!(on_screen(r(-50.0, 550.0, 200.0, 100.0), screen), r(0.0, 500.0, 200.0, 100.0));
assert_eq!(on_screen(r(700.0, -20.0, 200.0, 100.0), screen), r(600.0, 0.0, 200.0, 100.0));
// Larger than the screen: its top left stays in view.
assert_eq!(on_screen(r(-10.0, -10.0, 900.0, 700.0), screen), r(0.0, 0.0, 900.0, 700.0));
}
}
+7 -16
View File
@@ -151,21 +151,9 @@ fn set_layout(d: &mut Dialog, key: &str, v: Value) {
d.fields.insert("artboardLayout".into(), l);
}
/// The dimmed backdrop and a centred dialog window in the shared dialog style.
/// The New Document dialog window `id` in the shared modal frame.
fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui::Ui)) {
let t = Tokens::get(ctx);
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
egui::Window::new(id)
.id(egui::Id::new(("dialog", id)))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin)))
.show(ctx, add);
super::modal::show(ctx, id, egui::Id::new(("dialog", id)), -20.0, margin, add);
}
/// What the buttons of a window asked for.
@@ -194,6 +182,9 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let mut b = Buttons::default();
window(ctx, KIND, 0, |ui| {
// No heading: the top margin (14) and the empty part of the category tabs' row (30) move the
// window.
super::modal::drag_band(ui, ui.max_rect().top() + 44.0);
ui.horizontal_top(|ui| {
egui::Frame::NONE.inner_margin(egui::Margin { left: 22, right: 14, top: 14, bottom: 18 }).show(ui, |ui| {
ui.vertical(|ui| {
@@ -489,7 +480,7 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
const L: f32 = 150.0;
window(ctx, MORE, 22, |ui| {
ui.set_width(560.0);
ui.label(egui::RichText::new(tl!("More Settings")).font(theme::semibold(16.0)).color(t.text));
super::modal::heading(ui, tl!("More Settings"));
ui.add_space(12.0);
widgets::label_row(ui, tl!("Name:"), L, |ui| {
form::text(ui, &mut d, "name", 300.0);
@@ -533,7 +524,7 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
set_layout(&mut d, "spacing", json!(v.max(0.0)));
}
ui.add_space(20.0);
ui.label(egui::RichText::new(tl!("Columns:")).color(t.text));
widgets::field_label(ui, egui::RichText::new(tl!("Columns:")).color(t.text));
let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn);
ui.add_enabled_ui(grid, |ui| {
if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) {
+21 -7
View File
@@ -1,12 +1,12 @@
//! Path dialogs: Average, Offset Path, Simplify and Split Into Grid.
//! Path dialogs: Average, Offset Path (with Preview), Simplify and Split Into Grid.
use serde_json::{Value, json};
use super::{DialogSpec, run_and_close};
use super::{DialogSpec, form, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), confirm, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, preview: true, ..DialogSpec::FORM };
fn title(kind: &str) -> &'static str {
match kind {
@@ -17,14 +17,28 @@ fn title(kind: &str) -> &'static str {
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (id, params) = match d.kind.as_str() {
/// The command the dialog runs and its parameters.
fn command(d: &Dialog) -> (&'static str, Value) {
match d.kind.as_str() {
"average" => ("path.average", json!({"axis": d.str("axis")})),
"offsetPath" => {
("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)}))
}
"simplify" => ("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})),
_ => ("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})),
};
run_and_close(app, id, params)
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::grid(ui, d, app.session.general_unit());
if d.kind == "offsetPath" {
let (id, params) = command(d);
form::preview(app, ui, d, "Offset Path", id, params);
}
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (id, params) = command(d);
if d.kind == "offsetPath" { form::commit_preview(app, id, params) } else { run_and_close(app, id, params) }
}
+1 -1
View File
@@ -813,7 +813,7 @@ fn image_size(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option
if let Some(v) = widgets::plain_field(ui, "sfw-w", f64::from(w), " px", 0, 80.0) {
set(d, "width", json!(v.round().max(1.0)));
}
ui.label(tl!("H:"));
widgets::field_label(ui, tl!("H:"));
if let Some(v) = widgets::plain_field(ui, "sfw-h", f64::from(h), " px", 0, 80.0) {
set(d, "height", json!(v.round().max(1.0)));
}
+1 -1
View File
@@ -202,7 +202,7 @@ pub(super) fn option_fields(ui: &mut egui::Ui, d: &mut Dialog, screens: bool) {
choice(ui, d, "images", tl!("Images:"), &IMAGES, None);
}
choice(ui, d, "objectIds", tl!("Object IDs:"), &OBJECT_IDS, None);
ui.label(egui::RichText::new(tl!("Decimal:")).color(t.text_dim));
widgets::field_label(ui, egui::RichText::new(tl!("Decimal:")).color(t.text_dim));
let decimals = d.f64("decimals", 3.0);
if let Some(v) = widgets::spin_plain(ui, "svg-decimals", decimals, "", 0, 70.0, 1.0, 1.0, &[]) {
let (lo, hi) = (*vectorcraft_svg::DECIMALS.start() as f64, *vectorcraft_svg::DECIMALS.end() as f64);
+1 -1
View File
@@ -146,7 +146,7 @@ pub(super) fn grid<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -
/// A dimmed field label in the swatch dialogs' grid.
pub(super) fn label(ui: &mut egui::Ui, s: &str) {
ui.label(egui::RichText::new(s).color(Tokens::get(ui.ctx()).text_dim));
crate::widgets::field_label(ui, egui::RichText::new(s).color(Tokens::get(ui.ctx()).text_dim));
}
/// The Swatch Name row of the grid. Returns true when it changed.
+1
View File
@@ -4,6 +4,7 @@ use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
use crate::theme;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -9,6 +9,7 @@ use vectorcraft_engine::cmd::fileio;
use super::*;
use crate::Services;
use crate::theme;
pub(super) type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
@@ -7,6 +7,7 @@ use vectorcraft_testkit::pdf::{PdfPage, pdf};
use super::*;
use crate::io;
use crate::theme;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+203
View File
@@ -0,0 +1,203 @@
//! #423: modal dialogs move by their heading (and stay on screen and where they were moved), and
//! the form dialogs' choice fields are dropdowns (Offset Path's Joins).
use egui::{Event, Pos2, Rect, Vec2, vec2};
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
use crate::theme;
const SCREEN: Vec2 = vec2(1280.0, 900.0);
/// A context drawing the dialog layer in a 1280 × 900 window.
fn ctx() -> egui::Context {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
ctx
}
/// One frame of the dialog layer with `events`: every text drawn, with its screen rect.
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<Event>) -> Vec<(String, Rect)> {
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), events, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
fn collect(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
_ => {}
}
}
let mut texts = vec![];
out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts));
texts
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
}
/// Drag with the mouse from `from` by `by`, in steps, then let go.
fn drag(ctx: &egui::Context, app: &mut VectorcraftApp, from: Pos2, by: Vec2) {
frame(ctx, app, vec![Event::PointerMoved(from), button(from, true)]);
for i in 1..=6 {
frame(ctx, app, vec![Event::PointerMoved(from + by * (i as f32 / 6.0))]);
}
frame(ctx, app, vec![button(from + by, false)]);
frame(ctx, app, vec![]);
}
fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: Pos2) -> Vec<(String, Rect)> {
frame(ctx, app, vec![Event::PointerMoved(pos), button(pos, true)]);
frame(ctx, app, vec![button(pos, false)]);
frame(ctx, app, vec![])
}
/// Frames until a window that just opened has measured and placed itself.
fn settle(ctx: &egui::Context, app: &mut VectorcraftApp) {
for _ in 0..6 {
frame(ctx, app, vec![]);
}
}
fn rect_of(ctx: &egui::Context, id: egui::Id) -> Rect {
ctx.memory(|m| m.area_rect(id)).expect("the dialog window was shown")
}
fn app_with_selection() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 80, "height": 60})).unwrap();
app
}
fn close_to(a: Vec2, b: Vec2) -> bool {
(a - b).length() < 1.5
}
/// Opens a dialog.
type Open = fn(&mut VectorcraftApp);
/// Every kind of modal window: the shared frame (Offset Path), and the ones drawing their own
/// window (New Document without a heading, Preferences, Keyboard Shortcuts).
#[test]
fn dialogs_move_by_their_heading_stay_on_screen_and_where_they_were_moved() {
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
let opens: [(&str, Open, egui::Id); 4] = [
("Offset Path", |app| crate::menus::invoke(app, "object.path.offsetPath", json!({})), egui::Id::new(("dialog", "offsetPath"))),
("New Document", open_new_document, egui::Id::new(("dialog", "newDocument"))),
("Preferences", |app| crate::prefs_dialog::open(app, None), egui::Id::new("dialog-preferences")),
("Keyboard Shortcuts", crate::shortcut_editor::open, egui::Id::new("dialog-shortcuts")),
];
for (name, open, id) in opens {
let (ctx, mut app) = (ctx(), app_with_selection());
open(&mut app);
settle(&ctx, &mut app);
let start = rect_of(&ctx, id);
// Dragging the heading (the band from the top edge down to it) moves the window.
drag(&ctx, &mut app, start.left_top() + vec2(40.0, 10.0), vec2(-90.0, 50.0));
let moved = rect_of(&ctx, id);
assert!(close_to(moved.min - start.min, vec2(-90.0, 50.0)), "{name}: {start:?} → {moved:?}");
// A drag that starts below the heading (in the bottom margin) doesn't.
drag(&ctx, &mut app, moved.left_bottom() + vec2(6.0, -4.0), vec2(80.0, -80.0));
assert_eq!(rect_of(&ctx, id), moved, "{name}: dragging the body moved it");
// Dragged far past the window's edge, it stops at the edge.
drag(&ctx, &mut app, moved.left_top() + vec2(40.0, 10.0), vec2(5000.0, 5000.0));
let edge = rect_of(&ctx, id);
assert!(screen.contains_rect(edge.shrink(0.5)), "{name}: off screen at {edge:?}");
assert!((edge.right() - screen.right()).abs() < 1.5 && (edge.bottom() - screen.bottom()).abs() < 1.5, "{name}: {edge:?}");
// Closed and opened again (this session): where it was left.
app.ui.dialog = None;
frame(&ctx, &mut app, vec![]);
open(&mut app);
settle(&ctx, &mut app);
assert!(close_to(rect_of(&ctx, id).min - edge.min, Vec2::ZERO), "{name}: reopened at {:?}, left at {edge:?}", rect_of(&ctx, id));
}
}
/// Offset Path's Joins is a dropdown of Miter, Round and Bevel (it was a text field), listed after
/// Offset; choosing Round runs the command with round joins.
#[test]
fn offset_path_joins_is_a_dropdown() {
let (ctx, mut app) = (ctx(), app_with_selection());
app.run("select.all", json!({})).unwrap();
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
frame(&ctx, &mut app, vec![]);
let texts = frame(&ctx, &mut app, vec![]);
let at = |texts: &[(String, Rect)], s: &str| texts.iter().find(|(t, _)| t == s).map(|(_, r)| *r);
let (offset, joins, miter) = (at(&texts, "Offset:").unwrap(), at(&texts, "Joins:").unwrap(), at(&texts, "Miter Limit:").unwrap());
assert!(offset.top() < joins.top() && joins.top() < miter.top(), "Offset, Joins, Miter Limit: {texts:?}");
let current = at(&texts, "Miter").expect("Joins shows its choice");
assert!(at(&texts, "miter").is_none(), "Joins is a text field: {texts:?}");
// The list: Miter, Round, Bevel.
let open = click(&ctx, &mut app, current.center());
for choice in ["Round", "Bevel"] {
assert!(at(&open, choice).is_some(), "{choice} isn't listed: {open:?}");
}
let round = at(&open, "Round").unwrap();
click(&ctx, &mut app, round.center());
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["joins"], json!("round"));
// OK: the same result as the command with round joins (not miter).
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let made = |app: &VectorcraftApp| {
let st = app.session.doc().unwrap();
format!("{:?}", st.doc.node(st.selection.objects[0]).unwrap().kind)
};
let direct = |joins: &str| {
let mut app = app_with_selection();
app.run("select.all", json!({})).unwrap();
app.run("object.path.offsetPath", json!({"offset": 10, "joins": joins, "miterLimit": 4})).unwrap();
made(&app)
};
assert_eq!(made(&app), direct("round"));
assert_ne!(made(&app), direct("miter"));
}
/// The other form dialogs' choices are dropdowns too: Average's and Shear's axis.
#[test]
fn form_dialog_axes_are_dropdowns() {
for (id, shown) in [("path.average", "Both"), ("object.shear", "Horizontal"), ("object.reflect", "Vertical")] {
let (ctx, mut app) = (ctx(), app_with_selection());
app.run("select.all", json!({})).unwrap();
crate::menus::invoke(&mut app, id, json!({}));
frame(&ctx, &mut app, vec![]);
let texts = frame(&ctx, &mut app, vec![]);
assert!(texts.iter().any(|(t, _)| t == shown), "{id}: {texts:?}");
assert!(!texts.iter().any(|(t, _)| *t == shown.to_lowercase()), "{id} has a text field: {texts:?}");
}
}
/// The axis choices are translated in the "axis" context: in Japanese the plain Horizontal and
/// Vertical are the type orientations (horizontal and vertical writing).
#[test]
fn axis_choices_read_as_axes_in_every_complete_language() {
use crate::i18n::{LANGUAGES, Lang, tr, tr_ctx};
let ja = Lang::from_code("ja").unwrap();
assert_eq!(tr_ctx(ja, "axis", "Horizontal"), "水平方向");
assert_ne!(tr_ctx(ja, "axis", "Horizontal"), tr(ja, "Horizontal"));
for l in LANGUAGES.iter().filter(|l| l.complete_menus) {
for s in ["Horizontal", "Vertical", "Both"] {
assert!(l.source.lines().any(|line| line.starts_with(&format!("axis\t{s}\t"))), "{}: no axis row for {s}", l.code);
}
}
}
/// Offset Path previews on the canvas; Cancel rolls the preview back.
#[test]
fn offset_path_previews() {
let (ctx, mut app) = (ctx(), app_with_selection());
app.run("select.all", json!({})).unwrap();
let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::<usize>();
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
let texts = frame(&ctx, &mut app, vec![]);
let texts = if texts.is_empty() { frame(&ctx, &mut app, vec![]) } else { texts };
let preview = texts.iter().find(|(t, _)| t == "Preview").map(|(_, r)| *r).expect("a Preview checkbox");
click(&ctx, &mut app, preview.center());
frame(&ctx, &mut app, vec![]);
assert_eq!(count(&app), 2, "previewed");
cancel(&mut app);
assert_eq!(count(&app), 1, "rolled back");
}
@@ -5,6 +5,7 @@ use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
use crate::theme;
/// The save prompt's rect after a few frames in a window of `physical` pixels at UI scale `zoom`.
fn prompt(app: &mut VectorcraftApp, ctx: &egui::Context, physical: egui::Vec2, zoom: f32) -> (egui::Rect, egui::Rect) {
+58 -69
View File
@@ -5,7 +5,7 @@ use egui::Ui;
use serde_json::{Value, json};
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::theme::Tokens;
use crate::widgets;
use crate::{VectorcraftApp, font_menu};
@@ -37,77 +37,66 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let sample = font_menu::sample_text(app);
let mut close = false;
let mut act: Option<&str> = None;
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
egui::Window::new(tl!("Find Font"))
.id(egui::Id::new("dialog-find-font"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22)))
.show(ctx, |ui: &mut Ui| {
ui.set_width(380.0);
ui.label(egui::RichText::new(tl!("Find Font")).font(theme::semibold(16.0)).color(t.text));
ui.add_space(10.0);
widgets::subheader(ui, &crate::i18n::fmt(tl!("Fonts in Document: {count}"), &[("count", &list.len().to_string())]));
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.set_min_height(120.0);
ui.set_width(ui.available_width());
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
for (i, f) in list.iter().enumerate() {
let (note, color) = font_note(f);
let label = format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]);
let text = egui::RichText::new(label).color(color.unwrap_or(t.text));
if ui.selectable_label(i == sel, text).clicked() {
d.fields.insert("selected".into(), json!(i));
}
crate::dialogs::modal::show(ctx, tl!("Find Font"), egui::Id::new("dialog-find-font"), -40.0, 22, |ui: &mut Ui| {
ui.set_width(380.0);
crate::dialogs::modal::heading(ui, tl!("Find Font"));
ui.add_space(10.0);
widgets::subheader(ui, &crate::i18n::fmt(tl!("Fonts in Document: {count}"), &[("count", &list.len().to_string())]));
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.set_min_height(120.0);
ui.set_width(ui.available_width());
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
for (i, f) in list.iter().enumerate() {
let (note, color) = font_note(f);
let label = format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]);
let text = egui::RichText::new(label).color(color.unwrap_or(t.text));
if ui.selectable_label(i == sel, text).clicked() {
d.fields.insert("selected".into(), json!(i));
}
});
});
ui.add_space(10.0);
widgets::subheader(ui, tl!("Replace With Font"));
let fam = d.str("family");
ui.horizontal(|ui| {
// Picks the font only: nothing is previewed on the document.
let pick = font_menu::font_menu(ui, "ff-family", &fam, 220.0, sample.as_deref(), font_menu::MenuLook::of(app));
if let Some((f, style)) = font_menu::picked(app, pick) {
d.fields.insert("family".into(), json!(f));
d.fields.insert("style".into(), json!(style.unwrap_or_default()));
}
let styles = vectorcraft_text::FontDb::global().styles(&d.str("family"));
// "(closest)" is ours; the font's style names are shown as they are.
let closest = tl!("(closest)");
let mut opts: Vec<&str> = vec![closest];
opts.extend(styles.iter().map(String::as_str));
let style = d.str("style");
let cur = if style.is_empty() { closest } else { style.as_str() };
if let Some(i) = widgets::dropdown_names(ui, "ff-style", cur, &opts, 120.0) {
d.fields.insert("style".into(), json!(if i == 0 { String::new() } else { styles[i - 1].clone() }));
}
});
ui.add_space(14.0);
ui.horizontal(|ui| {
let has = sel < list.len();
if ui.add_enabled(has, egui::Button::new(tl!("Find"))).clicked() {
act = Some("find");
}
if ui.add_enabled(has, egui::Button::new(tl!("Change"))).on_hover_text(tl!("In the selected objects")).clicked() {
act = Some("change");
}
if ui.add_enabled(has, egui::Button::new(tl!("Change All"))).clicked() {
act = Some("changeAll");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("Done")).clicked() {
close = true;
}
});
});
});
ui.add_space(10.0);
widgets::subheader(ui, tl!("Replace With Font"));
let fam = d.str("family");
ui.horizontal(|ui| {
// Picks the font only: nothing is previewed on the document.
let pick = font_menu::font_menu(ui, "ff-family", &fam, 220.0, sample.as_deref(), font_menu::MenuLook::of(app));
if let Some((f, style)) = font_menu::picked(app, pick) {
d.fields.insert("family".into(), json!(f));
d.fields.insert("style".into(), json!(style.unwrap_or_default()));
}
let styles = vectorcraft_text::FontDb::global().styles(&d.str("family"));
// "(closest)" is ours; the font's style names are shown as they are.
let closest = tl!("(closest)");
let mut opts: Vec<&str> = vec![closest];
opts.extend(styles.iter().map(String::as_str));
let style = d.str("style");
let cur = if style.is_empty() { closest } else { style.as_str() };
if let Some(i) = widgets::dropdown_names(ui, "ff-style", cur, &opts, 120.0) {
d.fields.insert("style".into(), json!(if i == 0 { String::new() } else { styles[i - 1].clone() }));
}
});
ui.add_space(14.0);
ui.horizontal(|ui| {
let has = sel < list.len();
if ui.add_enabled(has, egui::Button::new(tl!("Find"))).clicked() {
act = Some("find");
}
if ui.add_enabled(has, egui::Button::new(tl!("Change"))).on_hover_text(tl!("In the selected objects")).clicked() {
act = Some("change");
}
if ui.add_enabled(has, egui::Button::new(tl!("Change All"))).clicked() {
act = Some("changeAll");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("Done")).clicked() {
close = true;
}
});
});
});
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
if let (Some(a), Some(from)) = (act, list.get(sel)) {
let r = match a {
@@ -157,7 +146,7 @@ mod tests {
app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Missing Family"})).unwrap();
open(&mut app);
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui.ctx()));
out.textures_delta.clear();
let d = app.ui.dialog.clone().unwrap();
+1
View File
@@ -10,6 +10,7 @@
# language names, OpenType, the built-in workspace names and the bracketed perspective grid preset
# names.
About VectorCraft O aplikaci VectorCraft
Align to Path Zarovnat k cestě
Join Our Discord Připojit se k našemu Discordu
Settings… Nastavení…
Language Jazyk
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -182,6 +182,7 @@
Artwork Colors Colores de la ilustración
As Multiple Assets Como varios recursos
As Single Asset Como un solo recurso
Ascender Ascendente
Ask When Modified Preguntar al modificar
Aspect Ratio Proporción
Asset Export Exportación de recursos
@@ -212,6 +213,7 @@
Background: Fondo:
Backspace Retroceso
Bar Graph Tool Herramienta Gráfico de barras
Baseline Línea de base
Baseline Shift Desplazamiento de línea de base
Basic Latin Latín básico
Batch Lote
@@ -283,6 +285,7 @@
Cap Extremo
Cap: Extremo:
Cascade Cascada
Center Centro
Center Artwork Centrar ilustración
Center on Artboard Centrar en la mesa de trabajo
Center-Justified Tab Tabulación centrada
@@ -472,6 +475,8 @@
Corners Esquinas
Chamfer Chaflán
Corners… Esquinas…
Descender Descendente
Flip Voltear
Inverted Round Redondeada invertida
Radius: Radio:
Round Redondeada
@@ -924,6 +929,7 @@
Fit Headline Ajustar titular
Decrease Font Size Reducir tamaño de fuente
Increase Font Size Aumentar tamaño de fuente
Spacing Espaciado
Step Type Ajustar texto por incrementos
Fit in View Encajar en la vista
Fit in Window Encajar en la ventana
@@ -2708,6 +2714,7 @@
Type becomes outlines, and strokes a CAD line can't draw become filled shapes. El texto se convierte en contornos, y los trazos que una línea CAD no puede dibujar se convierten en formas rellenas.
Type Object Selection by Path Only Selección de objetos de texto solo por trazado
Type on a Path Texto en trazado
Type on a Path Options Opciones de texto en trazado
Type on a Path Options… Opciones de texto en trazado…
Type on a Path Tool Herramienta Texto en trazado
Type Orientation Orientación del texto
@@ -2880,6 +2887,7 @@
Zoom to Selection Zoom a la selección
Zoom Tool Herramienta Zoom
Zoom with Mouse Wheel Zoom con la rueda del mouse
Scrub Numeric Fields by Dragging Ajustar campos numéricos arrastrando
{count} file(s) in {folder}{note}. Show the package folder? {count} archivo(s) en {folder}{note}. ¿Mostrar la carpeta del paquete?
{count} linked file(s) not found No se encontraron {count} archivo(s) vinculado(s)
{count} linked image(s) changed since this document was saved. Show the new versions? {count} imagen(es) vinculada(s) cambiaron desde que se guardó este documento. ¿Mostrar las versiones nuevas?
@@ -3013,3 +3021,11 @@
Version Versión
% of all commits % de todos los commits
Lines +/− Líneas +/−
Joins Uniones
Miter Inglete
Bevel Bisel
Offset Desplazamiento
Axis Eje
axis Horizontal Horizontal
axis Vertical Vertical
axis Both Ambos
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -11,13 +11,18 @@
About VectorCraft VectorCraft について
All Scripts すべての文字
Any Kind すべての種類
Ascender アセンダ
Baseline ベースライン
Bottom-to-Bottom Leading 欧文ベースライン基準の行送り
Center 中央
Character Alignment 文字揃え
Decorative 装飾
Descender ディセンダ
Em Box Bottom/Left 仮想ボディの下 / 左
Em Box Center 仮想ボディの中央
Em Box Top/Right 仮想ボディの上 / 右
Favorites お気に入り
Flip 反転
Japanese 日本語
Join Our Discord Discord に参加
Line-end Punctuation Half Width 行末約物半角
@@ -31,7 +36,9 @@
Serif / Mincho 明朝・セリフ
Settings… 環境設定…
Language 表示言語
Spacing 間隔
Top-to-Top Leading 仮想ボディの上基準の行送り
Type on a Path Options パス上文字オプション
UI Brightness UI の明るさ
Dark 暗い
Medium Dark やや暗い
@@ -2553,6 +2560,7 @@
Zoom Tool ズームツール
Zoom to Selection 選択範囲にズーム
Zoom with Mouse Wheel マウスホイールでズーム
Scrub Numeric Fields by Dragging ドラッグで数値フィールドを調整
[Default] is protected: New… starts an editable copy. [Default] は変更できません。「新規…」で編集できる複製を作ります。
[Unnamed] [名称未設定]
above (対象:次を超える画像)
@@ -3002,3 +3010,11 @@
Version バージョン
% of all commits 全コミットに占める割合
Lines +/− 行数 +/−
Joins 角の形状
Miter マイター
Bevel ベベル
Offset オフセット
Axis 軸
axis Horizontal 水平方向
axis Vertical 垂直方向
axis Both 両方
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2536,6 +2536,7 @@
Zoom Tool Ferramenta Zoom
Zoom to Selection Ampliar para a Seleção
Zoom with Mouse Wheel Zoom com a Roda do Mouse
Scrub Numeric Fields by Dragging Ajustar Campos Numéricos Arrastando
[Default] is protected: New… starts an editable copy. [Padrão] é protegido: Novo… inicia uma cópia editável.
[Unnamed] [Sem Nome]
above acima
Can't render this file because it contains an unexpected character in line 6 and column 47.
+15
View File
@@ -183,6 +183,7 @@
Artwork Colors 图稿颜色
As Multiple Assets 作为多个资源
As Single Asset 作为单一资源
Ascender 上缘
Ask When Modified 修改时询问
Aspect Ratio 长宽比
Asset Export 资源导出
@@ -214,6 +215,7 @@
Background: 背景:
Backspace Backspace
Bar Graph Tool 条形图工具
Baseline 基线
Baseline Shift 基线偏移
Basic Latin 基本拉丁文
Batch 批处理
@@ -471,6 +473,8 @@
Corner Radius: 圆角半径:
Corner: 转角:
Corners 转角
Descender 下缘
Flip 翻转
Round 圆角
Inverted Round 反向圆角
Corners… 转角…
@@ -918,6 +922,7 @@
Fit Headline 适合标题
Decrease Font Size 减小字体大小
Increase Font Size 增大字体大小
Spacing 间距
Step Type 按增量调整文字
Fit in View 适合视图
Fit in Window 适合窗口
@@ -2647,6 +2652,7 @@
Type becomes outlines, and strokes a CAD line can't draw become filled shapes. 文字变为轮廓,CAD 无法绘制的描边变为填充形状。
Type Object Selection by Path Only 仅按路径选择文字对象
Type on a Path 路径文字
Type on a Path Options 路径文字选项
Type on a Path Options… 路径文字选项…
Type on a Path Tool 路径文字工具
Type Orientation 文字方向
@@ -2821,6 +2827,7 @@
Zoom to Selection 缩放到所选对象
Zoom Tool 缩放工具
Zoom with Mouse Wheel 使用鼠标滚轮缩放
Scrub Numeric Fields by Dragging 拖动以调整数值字段
{count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 个文件{note}。要显示打包文件夹吗?
{count} linked file(s) not found 找不到 {count} 个链接文件
{count} linked image(s) changed since this document was saved. Show the new versions? 自存储此文档后,有 {count} 个链接图像已更改。要显示新版本吗?
@@ -3260,3 +3267,11 @@
Version 版本
% of all commits 占全部提交的百分比
Lines +/− 行数 +/−
Joins 连接
Miter 斜接
Bevel 斜角
Offset 偏移
Axis 轴
axis Horizontal 水平
axis Vertical 垂直
axis Both 两者
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -194,6 +194,7 @@
Artwork Colors 圖稿顏色
As Multiple Assets 作為多個資產
As Single Asset 作為單一資產
Ascender 上緣
Ask When Modified 修改時詢問
Aspect Ratio 長寬比
Asset Export 資產匯出
@@ -225,6 +226,7 @@
Background: 背景:
Backspace Backspace
Bar Graph Tool 橫條圖工具
Baseline 基線
Baseline Shift 基線位移
Basic Latin 基本拉丁文
Batch 批次
@@ -297,6 +299,7 @@
Cap 端點
Cap: 端點:
Cascade 重疊顯示
Center 置中
Center Artwork 圖稿置中
Center on Artboard 在工作區域置中
Center-Justified Tab 置中對齊定位點
@@ -484,6 +487,8 @@
Corner Radius: 圓角半徑:
Corner: 轉角:
Corners 轉角
Descender 下緣
Flip 翻轉
Round 圓角
Radius: 半徑:
Inverted Round 反向圓角
@@ -940,6 +945,7 @@
Fit Headline 符合標題
Decrease Font Size 縮小字體大小
Increase Font Size 放大字體大小
Spacing 間距
Step Type 依增量調整文字
Fit in View 符合檢視
Fit in Window 符合視窗
@@ -2729,6 +2735,7 @@
Type becomes outlines, and strokes a CAD line can't draw become filled shapes. 文字會變成外框,CAD 線條無法繪製的筆畫會變成填色形狀。
Type Object Selection by Path Only 僅以路徑選取文字物件
Type on a Path 路徑文字
Type on a Path Options 路徑文字選項
Type on a Path Options… 路徑文字選項…
Type on a Path Tool 路徑文字工具
Type Orientation 文字方向
@@ -2904,6 +2911,7 @@
Zoom to Selection 縮放至選取範圍
Zoom Tool 縮放工具
Zoom with Mouse Wheel 使用滑鼠滾輪縮放
Scrub Numeric Fields by Dragging 拖曳以調整數值欄位
{count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 個檔案{note}。要顯示封裝資料夾嗎?
{count} linked file(s) not found 找不到 {count} 個連結檔案
{count} linked image(s) changed since this document was saved. Show the new versions? 自儲存此文件後,有 {count} 個連結影像已變更。要顯示新版本嗎?
@@ -3033,3 +3041,11 @@
Version 版本
% of all commits 佔全部提交的比例
Lines +/− 行數 +/−
Joins 轉角
Miter 尖角
Bevel 斜角
Offset 位移
Axis 軸
axis Horizontal 水平
axis Vertical 垂直
axis Both 兩者
Can't render this file because it contains an unexpected character in line 6 and column 47.
+11
View File
@@ -38,6 +38,7 @@ pub mod prefs_dialog;
pub mod print;
pub mod recovery;
pub mod render_worker;
mod scrub;
pub mod shortcut_editor;
pub mod shortcuts;
pub mod state;
@@ -85,6 +86,8 @@ mod tests_paintchips;
#[cfg(test)]
mod tests_pastechords;
#[cfg(test)]
mod tests_pathtype;
#[cfg(test)]
mod tests_pdfoutput;
#[cfg(test)]
mod tests_place;
@@ -107,6 +110,8 @@ mod tests_save;
#[cfg(test)]
mod tests_saveext;
#[cfg(test)]
mod tests_scrub;
#[cfg(test)]
mod tests_selectall;
#[cfg(test)]
mod tests_slices;
@@ -331,6 +336,9 @@ pub struct VectorcraftApp {
pub(crate) ime_marked: Option<String>,
/// The IME must drop its marked text (see [`Self::take_ime_discard`]).
pub(crate) ime_discard: bool,
/// A numeric field is being scrubbed: the document's edits meanwhile are one undo step
/// ([`scrub::begin_frame`]).
scrub_group: bool,
}
/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`].
@@ -394,6 +402,7 @@ impl VectorcraftApp {
synthetic_modifiers: false,
ime_marked: None,
ime_discard: false,
scrub_group: false,
}
}
@@ -864,6 +873,7 @@ impl VectorcraftApp {
return;
}
let t0 = now_ms();
scrub::begin_frame(self, &ctx);
font_menu::end_stale_preview(self, &ctx);
let t = theme::Tokens::get(&ctx);
if self.ui.screen_mode < 2 {
@@ -896,6 +906,7 @@ impl VectorcraftApp {
titlebar::resize_zones(ui);
}
self.ui_fonts.frame(&ctx);
scrub::end_frame(self, &ctx);
self.perf.frame_ms = now_ms() - t0;
let _ = json!(null);
}
+31 -13
View File
@@ -2075,15 +2075,11 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Area Type Options…", "text.areaOptions"),
sub(
"Type on a Path",
vec![
cp("Rainbow", "type.pathOptions", json!({"effect": "rainbow"})),
cp("Skew", "type.pathOptions", json!({"effect": "skew"})),
cp("3D Ribbon", "type.pathOptions", json!({"effect": "3dRibbon"})),
cp("Stair Step", "type.pathOptions", json!({"effect": "stairStep"})),
cp("Gravity", "type.pathOptions", json!({"effect": "gravity"})),
Sep,
cp("Type on a Path Options…", "type.pathOptions", json!({"start": 0})),
],
PATH_EFFECTS
.iter()
.map(|&(label, effect)| cp(label, "type.pathOptions", json!({ "effect": effect })))
.chain([Sep, c("Type on a Path Options…", "type.pathOptions")])
.collect(),
),
sub(
"Threaded Text",
@@ -2658,6 +2654,17 @@ pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) {
(id.to_string(), if p.is_null() { json!({}) } else { p.clone() })
}
/// The dialog of command `id` that shows the selection's current values (its query): its heading
/// and the queried values it doesn't show. Type on a Path Options leaves the brackets where they
/// are.
fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> {
Some(match id {
"text.areaOptions" => ("Area Type Options", &[]),
"type.pathOptions" => ("Type on a Path Options", &["start", "end"]),
_ => return None,
})
}
/// The dialog (kind, fields) the menu item of command `id` opens, as the reference app's does.
fn menu_dialog(id: &str) -> Option<(&'static str, Value)> {
Some(match id {
@@ -2728,11 +2735,17 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields}));
return;
}
// Area Type Options: a dialog with the selected area type's current values.
if id == "text.areaOptions" && p.as_object().is_none_or(|o| o.is_empty()) {
// Area Type Options and Type on a Path Options: dialogs with the selected type's current
// values (the command's query), less those the dialog leaves alone.
if let Some((label, hidden)) = queried_dialog(id)
&& p.as_object().is_none_or(|o| o.is_empty())
{
match app.session.execute(id, &json!({})) {
Ok(fields) => {
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Area Type Options", "params": fields}));
Ok(mut fields) => {
if let Some(o) = fields.as_object_mut() {
o.retain(|k, _| !hidden.contains(&k.as_str()));
}
let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields}));
}
Err(e) => app.status(e.to_string()),
}
@@ -2937,6 +2950,11 @@ const TYPE_SIZES: [(&str, u32); 14] = [
("72 pt", 72),
];
/// Type → Type on a Path effects (label, `type.pathOptions` effect), also the choices of its
/// Options dialog.
pub(crate) const PATH_EFFECTS: &[(&str, &str)] =
&[("Rainbow", "rainbow"), ("Skew", "skew"), ("3D Ribbon", "3dRibbon"), ("Stair Step", "stairStep"), ("Gravity", "gravity")];
const INSERT_SPECIAL: &[(&str, &str)] = &[
("Bullet", "bullet"),
("Copyright Symbol", "copyright"),
+7 -10
View File
@@ -668,16 +668,13 @@ fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opaci
let lx = r.left() + EYE_W + 24.0 + if item.is_some() { 12.0 } else { 0.0 };
let clicked = link(ui, pos2(lx, r.center().y), ("ap-op-link", item), tl!("Opacity:")) || resp.clicked();
text(ui, pos2(lx + 56.0, r.center().y), &opacity_text(opacity, blend), false);
egui::Popup::menu(&resp)
.open_memory(clicked.then_some(egui::SetOpenCommand::Toggle))
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| match widgets::opacity_blend(ui, ("ap-op", item), Some(opacity), Some(blend), true) {
Some(TransparencyEdit::Blend(b)) => {
app.run("transparency.set", json!({"item": item, "blend": b.label()})).ok();
}
Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase),
None => {}
});
widgets::popover(&resp, clicked, |ui| match widgets::opacity_blend(ui, ("ap-op", item), Some(opacity), Some(blend), true) {
Some(TransparencyEdit::Blend(b)) => {
app.run("transparency.set", json!({"item": item, "blend": b.label()})).ok();
}
Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase),
None => {}
});
r
}
+3 -1
View File
@@ -227,7 +227,9 @@ pub(crate) fn route_type_input(app: &mut VectorcraftApp, ctx: &egui::Context) {
fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(label).size(11.5).strong().color(t.text))).on_hover_text(tl!(tip));
let l =
ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(label).size(11.5).strong().color(t.text))).on_hover_text(tl!(tip));
crate::scrub::note_label(ui, l.rect);
add(ui);
});
}
+2 -1
View File
@@ -89,7 +89,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Indents and paragraph spacing are distances (General); type sizes follow Units ▸ Type.
let unit = app.session.general_unit();
let label = |ui: &mut Ui, s: &str, tip: &str| {
ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(s).size(11.5).strong().color(t.text))).on_hover_text(tip);
let l = ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(s).size(11.5).strong().color(t.text))).on_hover_text(tip);
crate::scrub::note_label(ui, l.rect);
};
egui::Grid::new("para-grid").num_columns(4).spacing([4.0, 4.0]).show(ui, |ui| {
label(ui, "→|", tl!("Left Indent"));
+4 -3
View File
@@ -299,7 +299,8 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
super::transform::constrain_link(app, ui);
});
ui.horizontal(|ui| {
icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon);
let icon = icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon);
crate::scrub::note_label(ui, icon.rect);
// The bounding box's angle: a new value turns the selection to it.
if let Some(a) = widgets::plain_field(ui, "rot", bx.angle, "°", 2, 70.0) {
app.run("object.rotate", json!({"angle": a, "absolute": true})).ok();
@@ -345,7 +346,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
for (label, is_fill) in [(tl!("Fill"), true), (tl!("Stroke"), false)] {
ui.horizontal(|ui| {
super::paint_chip(app, ui, !is_fill, 22.0, false);
ui.label(egui::RichText::new(label).size(12.0));
widgets::field_label(ui, egui::RichText::new(label).size(12.0));
if !is_fill {
ui.add_space(8.0);
if let Some(w) = widgets::num_field(ui, "ap-w", weight, app.session.stroke_unit(), 70.0) {
@@ -357,7 +358,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
});
}
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Opacity")).size(12.0));
widgets::field_label(ui, egui::RichText::new(tl!("Opacity")).size(12.0));
ui.add_space(8.0);
if let Some(o) = widgets::plain_field(ui, "ap-op", n.opacity as f64 * 100.0, "%", 0, 64.0) {
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
+33 -5
View File
@@ -4,7 +4,7 @@
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, ArrowAlign, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, WidthProfile};
use vectorcraft_doc::{Appearance, ArrowAlign, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, Unit, WidthProfile};
use vectorcraft_engine::inspect::StrokeMixed;
use super::{character, current_stroke, pstate, set_pstate, stroke_mixed};
@@ -12,8 +12,34 @@ use crate::theme::Tokens;
use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
pub const WEIGHT_PRESETS: [f64; 22] =
/// Stroke weight dropdown presets in points, from the ladder of `unit` (Units > Stroke): each
/// unit has its own ladder of round values in that unit, so the dropdown reads `0.25 mm`, not the
/// `0.088 mm` a converted pt ladder gives. Feet, yards and meters, which no stroke is measured
/// in, keep the pt ladder.
pub fn weight_presets(unit: Unit) -> [f64; 22] {
let native = match unit {
Unit::Millimeters => MM_PRESETS,
Unit::Centimeters => CM_PRESETS,
Unit::Inches => IN_PRESETS,
Unit::Pixels => PX_PRESETS,
Unit::Picas => return PC_PRESETS,
Unit::Points | Unit::FeetInches | Unit::Feet | Unit::Meters | Unit::Yards => return PT_PRESETS,
};
native.map(|v| unit.to_pt(v))
}
const PT_PRESETS: [f64; 22] =
[0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
const MM_PRESETS: [f64; 22] = [0.1, 0.25, 0.35, 0.5, 0.75, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 15.0, 20.0, 25.0, 30.0];
const CM_PRESETS: [f64; 22] = [0.01, 0.02, 0.03, 0.05, 0.06, 0.07, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0];
const IN_PRESETS: [f64; 22] =
[0.0078, 0.0156, 0.0313, 0.0625, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0];
const PX_PRESETS: [f64; 22] =
[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 30.0, 40.0];
/// The pica ladder (0p1 … 5p) in points, so every entry is an exact pt weight. The dropdown shows
/// them as decimal picas (`0.083 p`) until [`Unit::number`] writes `0p1` notation.
const PC_PRESETS: [f64; 22] =
[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 15.0, 18.0, 21.0, 24.0, 27.0, 30.0, 33.0, 36.0, 48.0, 60.0];
/// A profile's points as its silhouette: none (the plain bar) for the uniform stroke.
fn silhouette(points: &[(f64, f64, f64)]) -> Option<&[(f64, f64, f64)]> {
@@ -87,7 +113,8 @@ pub(crate) fn shown_weight(app: &VectorcraftApp, st: Option<&StrokeLayer>, mixed
/// The weight spinner (Stroke panel, Control bar): Units > Stroke, presets, blank when mixed.
pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weight: Option<f64>, width: f32) {
if let Some(w) = widgets::spin_field(ui, id, weight, app.session.stroke_unit(), width, 1.0, 0.0, &WEIGHT_PRESETS) {
let unit = app.session.stroke_unit();
if let Some(w) = widgets::spin_field(ui, id, weight, unit, width, 1.0, 0.0, &weight_presets(unit)) {
set(app, json!({"weight": w}));
}
}
@@ -95,7 +122,7 @@ pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weig
/// The Stroke panel in a popover anchored to `resp` (the Control bar's and the Properties
/// panel's Stroke links), which a click on it toggles.
pub(crate) fn popover(app: &mut VectorcraftApp, resp: &egui::Response) {
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
widgets::popover(resp, resp.clicked(), |ui| {
ui.set_width(260.0);
show(app, ui);
});
@@ -110,7 +137,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let hidden: bool = pstate(ui.ctx(), "stroke-hide-options");
let label_w = 64.0;
let row_label = |ui: &mut Ui, s: &str| {
ui.add_sized(vec2(label_w, 24.0), egui::Label::new(egui::RichText::new(s).size(12.5).color(t.text)).halign(egui::Align::RIGHT));
let l = ui.add_sized(vec2(label_w, 24.0), egui::Label::new(egui::RichText::new(s).size(12.5).color(t.text)).halign(egui::Align::RIGHT));
crate::scrub::note_label(ui, l.rect);
};
ui.horizontal(|ui| {
row_label(ui, tl!("Weight:"));
@@ -168,6 +168,29 @@ fn item_opacity_popup_sets_the_items_opacity() {
assert_eq!((n.appearance.items[0].opacity(), n.opacity), (1.0, 1.0));
}
#[test]
fn opacity_popup_blend_dropdown_sets_the_blend_mode() {
let mut app = app_with_rect();
let ctx = egui::Context::default();
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
// The object's own Opacity row (the last one).
let link = texts.iter().rev().find(|(t, _)| t == "Opacity:").expect("object opacity row").1;
click(&ctx, &mut app, link.center(), appearance::show);
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
// Opening the dropdown inside the popup keeps the popup open: the list shows.
click(&ctx, &mut app, text_rect(&texts, "Normal").center(), appearance::show);
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
click(&ctx, &mut app, text_rect(&texts, "Multiply").center(), appearance::show);
assert_eq!(first_selected(&app).unwrap().blend, vectorcraft_color::BlendMode::Multiply);
// The popup is still open after choosing.
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
assert!(texts.iter().any(|(t, _)| t == "Multiply"), "{texts:?}");
// A frame without the panel closes it, as egui closes the popups it remembers.
frame_events(&ctx, &mut app, vec![], |_, _| {});
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
assert!(!texts.iter().any(|(t, _)| t == "Multiply"), "{texts:?}");
}
#[test]
fn object_row_names_the_selection_like_the_control_bar() {
let mut app = app_with_rect();
+29 -3
View File
@@ -32,10 +32,10 @@ fn weights_and_dashes_show_in_the_stroke_unit_everywhere() {
app.session.prefs.units_stroke = "millimeters".into();
run(&mut app, "stroke.set", json!({"dash": [12, 6]}));
let mm = vectorcraft_doc::Unit::Millimeters.format(1.0);
assert_eq!(mm, "0.353 mm");
assert_eq!(mm, "0.3528 mm");
let t = texts(&ctx, &mut app, stroke::show);
assert!(shows(&t, &mm), "Stroke panel: {t:?}");
assert!(shows(&t, "4.233") && shows(&t, "2.117"), "dash and gap in mm: {t:?}");
assert!(shows(&t, "4.2333") && shows(&t, "2.1167"), "dash and gap in mm: {t:?}");
assert!(shows(&texts(&ctx, &mut app, properties::show), &mm), "Properties panel");
assert!(shows(&texts(&ctx, &mut app, crate::chrome::control_bar), &mm), "Control bar");
}
@@ -75,7 +75,8 @@ fn inside_and_outside_are_off_for_open_paths_and_type() {
#[test]
fn the_weight_presets_run_from_a_quarter_point_to_a_hundred() {
assert!(stroke::WEIGHT_PRESETS.contains(&0.25) && stroke::WEIGHT_PRESETS.contains(&100.0));
let pt = stroke::weight_presets(vectorcraft_doc::Unit::Points);
assert!(pt.contains(&0.25) && pt.contains(&100.0));
let ctx = egui::Context::default();
let mut app = app_with_rect();
// The chevron at the right end of the 120 px weight spinner opens them.
@@ -86,3 +87,28 @@ fn the_weight_presets_run_from_a_quarter_point_to_a_hundred() {
let t = texts(&ctx, &mut app, stroke::show);
assert!(shows(&t, "0.25 pt") && shows(&t, "100 pt"), "{t:?}");
}
#[test]
fn weight_presets_display_cleanly_in_their_native_unit() {
use vectorcraft_doc::Unit;
// Round-tripping a native-unit value through to_pt → from_pt → number() must give the same
// string the ladder was authored with: no "0.353 mm" surprises in the dropdown.
for (unit, want) in [
(Unit::Millimeters, ["0.1", "0.25", "0.35", "0.5", "0.75", "1", "30"].as_slice()),
(Unit::Centimeters, ["0.01", "0.05", "0.1", "0.5", "1", "5"].as_slice()),
(Unit::Pixels, ["1", "10", "20", "40"].as_slice()),
(Unit::Inches, ["0.0078", "0.0313", "0.375", "1", "5"].as_slice()),
(Unit::Points, ["0.25", "0.5", "1", "50", "100"].as_slice()),
] {
let presets = stroke::weight_presets(unit);
let shown: Vec<String> = presets.iter().map(|pt| unit.number(*pt)).collect();
for w in want {
assert!(shown.iter().any(|s| s == w), "{unit:?} preset {w:?} missing from {shown:?}");
}
}
// Picas are exact pt weights (0p1 = 1 pt, 1p = 12 pt, 5p = 60 pt).
let pc = stroke::weight_presets(Unit::Picas);
assert!([1.0, 12.0, 60.0].iter().all(|w| pc.contains(w)), "{pc:?}");
// Units no stroke is measured in keep the pt ladder, in points.
assert_eq!(stroke::weight_presets(Unit::Meters), stroke::weight_presets(Unit::Points));
}
+1 -1
View File
@@ -37,7 +37,7 @@ fn general_mm(app: &mut VectorcraftApp) {
#[test]
fn panels_show_millimetres_after_the_preference_changes() {
let mut app = app_with_rect();
assert_eq!(side_mm(), "35.278 mm");
assert_eq!(side_mm(), "35.2778 mm");
assert!(shows(&texts(&mut app, transform::show), "100 pt"), "points first");
general_mm(&mut app);
// The next frame: no restart, nothing cached.
+4 -2
View File
@@ -139,7 +139,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let origin = rp.map(|p| json!([p.x, p.y]));
ui.horizontal(|ui| {
ui.add_enabled_ui(has, |ui| {
icons::icon(ui, "rotate-ccw", 16.0, t.icon).on_hover_text(tl!("Rotate"));
let icon = icons::icon(ui, "rotate-ccw", 16.0, t.icon).on_hover_text(tl!("Rotate"));
crate::scrub::note_label(ui, icon.rect);
// The bounding box's angle: a new value turns the selection to it.
let angle = bx.map_or(0.0, |b| b.angle);
if let Some(a) = widgets::spin_plain(ui, "xfp-rot", angle, "°", 2, 96.0, 15.0, -360.0, &ANGLE_PRESETS)
@@ -148,7 +149,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.run("object.rotate", json!({"angle": a, "absolute": true, "origin": origin})).ok();
}
ui.add_space(4.0);
icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text(tl!("Shear"));
let icon = icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text(tl!("Shear"));
crate::scrub::note_label(ui, icon.rect);
if let Some(a) = widgets::plain_field(ui, "xfp-shear", 0.0, "°", 1, 56.0)
&& a != 0.0
{
+61 -68
View File
@@ -78,6 +78,7 @@ struct Applied {
white_canvas: bool,
threads: i32,
tool_tips: bool,
scrub: bool,
}
/// Per frame: push UI-side preferences into egui / the renderer when they change.
@@ -88,8 +89,13 @@ pub fn apply_runtime(app: &mut VectorcraftApp, ctx: &egui::Context) {
{
app.ui.brightness = b;
}
let want =
Applied { brightness: app.ui.brightness, white_canvas: p.canvas_color == "white", threads: p.render_threads, tool_tips: p.show_tool_tips };
let want = Applied {
brightness: app.ui.brightness,
white_canvas: p.canvas_color == "white",
threads: p.render_threads,
tool_tips: p.show_tool_tips,
scrub: p.scrub_numeric_fields,
};
let id = egui::Id::new("dc-applied-prefs");
let prev: Option<Applied> = ctx.data(|d| d.get_temp::<Option<Applied>>(id)).flatten();
if prev != Some(want) {
@@ -98,6 +104,7 @@ pub fn apply_runtime(app: &mut VectorcraftApp, ctx: &egui::Context) {
// due), whichever widget asks for one.
let delay = if want.tool_tips { egui::style::Interaction::default().tooltip_delay } else { f32::INFINITY };
ctx.global_style_mut(|s| s.interaction.tooltip_delay = delay);
crate::scrub::set_enabled(ctx, want.scrub);
if want.white_canvas {
let mut t = Tokens::get(ctx);
t.pasteboard = egui::Color32::WHITE;
@@ -122,81 +129,67 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.dialog.clone() else { return };
let t = Tokens::get(ctx);
let (mut ok, mut cancel, mut reset) = (false, false, false);
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
let cat = d.str("__category");
let cat = PREF_CATEGORIES.iter().find(|c| **c == cat).copied().unwrap_or(PREF_CATEGORIES[0]);
egui::Window::new(tl!("Preferences"))
.id(egui::Id::new("dialog-preferences"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
.show(ctx, |ui| {
ui.set_width(760.0);
ui.label(egui::RichText::new(tl!("Preferences")).font(theme::semibold(16.0)).color(t.text));
ui.add_space(12.0);
ui.horizontal_top(|ui| {
// Category list.
egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(
ui,
|ui| {
ui.set_width(196.0);
ui.set_min_height(430.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
for c in PREF_CATEGORIES {
let sel = *c == cat;
let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text });
let b = egui::Button::selectable(sel, text).frame_when_inactive(false).min_size(egui::vec2(184.0, 24.0));
if ui.add(b).clicked() {
d.fields.insert("__category".into(), json!(c));
}
}
});
},
);
ui.add_space(14.0);
// Fields.
crate::dialogs::modal::show(ctx, tl!("Preferences"), egui::Id::new("dialog-preferences"), -20.0, 20, |ui| {
ui.set_width(760.0);
crate::dialogs::modal::heading(ui, tl!("Preferences"));
ui.add_space(12.0);
ui.horizontal_top(|ui| {
// Category list.
egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
ui.set_width(196.0);
ui.set_min_height(430.0);
ui.vertical(|ui| {
ui.set_width(530.0);
ui.label(egui::RichText::new(tl!(cat)).font(theme::semibold(14.0)).color(t.text_strong));
ui.add_space(8.0);
egui::ScrollArea::vertical().id_salt(("prefs", cat)).max_height(400.0).auto_shrink([false, false]).show(ui, |ui| {
category_fields(ui, &mut d, cat);
});
ui.spacing_mut().item_spacing.y = 1.0;
for c in PREF_CATEGORIES {
let sel = *c == cat;
let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text });
let b = egui::Button::selectable(sel, text).frame_when_inactive(false).min_size(egui::vec2(184.0, 24.0));
if ui.add(b).clicked() {
d.fields.insert("__category".into(), json!(c));
}
}
});
});
ui.add_space(14.0);
ui.horizontal(|ui| {
if widgets::secondary_button(ui, tl!("Reset Preferences"))
.on_hover_text(tl!("Restore every preference to its default (applied on OK)"))
.clicked()
{
reset = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("OK")).clicked() {
ok = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
ui.add_space(16.0);
let i = PREF_CATEGORIES.iter().position(|c| *c == cat).unwrap_or(0);
if ui.add_enabled(i + 1 < PREF_CATEGORIES.len(), egui::Button::new(tl!("Next"))).clicked() {
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i + 1]));
}
if ui.add_enabled(i > 0, egui::Button::new(tl!("Previous"))).clicked() {
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i - 1]));
}
// Fields.
ui.vertical(|ui| {
ui.set_width(530.0);
ui.label(egui::RichText::new(tl!(cat)).font(theme::semibold(14.0)).color(t.text_strong));
ui.add_space(8.0);
egui::ScrollArea::vertical().id_salt(("prefs", cat)).max_height(400.0).auto_shrink([false, false]).show(ui, |ui| {
category_fields(ui, &mut d, cat);
});
});
});
ui.add_space(14.0);
ui.horizontal(|ui| {
if widgets::secondary_button(ui, tl!("Reset Preferences"))
.on_hover_text(tl!("Restore every preference to its default (applied on OK)"))
.clicked()
{
reset = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("OK")).clicked() {
ok = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
ui.add_space(16.0);
let i = PREF_CATEGORIES.iter().position(|c| *c == cat).unwrap_or(0);
if ui.add_enabled(i + 1 < PREF_CATEGORIES.len(), egui::Button::new(tl!("Next"))).clicked() {
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i + 1]));
}
if ui.add_enabled(i > 0, egui::Button::new(tl!("Previous"))).clicked() {
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i - 1]));
}
});
});
});
if reset {
for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() {
d.fields.insert(k, v);
+205
View File
@@ -0,0 +1,205 @@
//! Scrubbing numeric fields (#400, beyond Illustrator; Photoshop's scrubby labels): a horizontal
//! drag on a numeric field's label (`W:`, `Opacity:`, the Character panel's size icon) steps the
//! field's value by one unit every [`PX_PER_STEP`] points, ten with Shift and a tenth with
//! Ctrl/Cmd, as the arrow keys step a focused field; a click on the label focuses the field. The
//! text box itself never scrubs, and a field without a label doesn't.
//!
//! The panels apply each value the drag passes as they apply a typed one, so the art follows the
//! drag live; the app holds an undo group open meanwhile ([`begin_frame`], [`end_frame`]), so the
//! drag is one undo step and Escape takes it back. Preferences › General › Scrub Numeric Fields by
//! Dragging turns it off.
use egui::{Context, CursorIcon, Id, Key, Rect, Response, Sense, Ui};
use crate::VectorcraftApp;
/// Points of horizontal drag per step.
pub const PX_PER_STEP: f32 = 2.0;
/// The widest gap between a label and the field it names.
const LABEL_GAP: f32 = 64.0;
/// Where the scrub in progress is kept (egui memory).
fn state_id() -> Id {
Id::new("numeric-field-scrub")
}
/// Where the last label drawn waits for its field.
fn label_id() -> Id {
Id::new("numeric-field-label")
}
fn enabled_id() -> Id {
Id::new("numeric-field-scrub-enabled")
}
/// Turn scrubbing on or off (General › Scrub Numeric Fields by Dragging; on until set).
pub(crate) fn set_enabled(ctx: &Context, on: bool) {
ctx.data_mut(|d| d.insert_temp(enabled_id(), on));
}
fn enabled(ctx: &Context) -> bool {
ctx.data(|d| d.get_temp(enabled_id())).unwrap_or(true)
}
/// The scrub in progress.
#[derive(Clone, Copy, Debug)]
struct Scrub {
/// The zone dragged: a field or its label.
zone: Id,
/// The field's value when the drag started (in its units), which Escape puts back.
start: f64,
/// Drag not applied yet, in the field's units.
pending: f64,
/// The step `pending` counts in (it changes with Shift and Ctrl/Cmd).
step: f64,
/// A value was applied.
moved: bool,
/// Escape ended the drag.
cancelled: bool,
}
/// Where the scrub of a numeric field stands this frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Phase {
Idle,
Dragging,
/// Released this frame.
Released,
/// Escape ended it this frame.
Cancelled,
}
pub(crate) fn phase(ctx: &Context) -> Phase {
let Some(s) = ctx.data(|d| d.get_temp::<Scrub>(state_id())) else { return Phase::Idle };
if ctx.drag_stopped_id() == Some(s.zone) {
if s.cancelled { Phase::Cancelled } else { Phase::Released }
} else if ctx.is_being_dragged(s.zone) {
Phase::Dragging
} else {
Phase::Idle
}
}
/// Before the panels draw: while a field is scrubbed, the edits its values make are one undo step.
/// (The drag's first frame applies nothing, so the group is open before its first value.)
pub(crate) fn begin_frame(app: &mut VectorcraftApp, ctx: &Context) {
if !app.scrub_group && matches!(phase(ctx), Phase::Dragging | Phase::Released) {
app.session.begin_undo_group();
app.scrub_group = true;
}
}
/// After the panels: a finished drag closes its undo group; Escape undoes it.
pub(crate) fn end_frame(app: &mut VectorcraftApp, ctx: &Context) {
let phase = phase(ctx);
if !app.scrub_group || phase == Phase::Dragging {
return;
}
app.scrub_group = false;
app.session.end_undo_group(phase == Phase::Cancelled);
app.sync_views();
}
/// A field's label drawn this pass, waiting for the field after it.
#[derive(Clone, Copy, Debug)]
struct Label {
rect: Rect,
pass: u64,
}
/// Note the label (text or icon) drawn at `rect`: a numeric field drawn right after it on the
/// same line takes it as its label, which scrubs the field.
pub(crate) fn note_label(ui: &Ui, rect: Rect) {
let pass = ui.ctx().cumulative_pass_nr();
ui.data_mut(|d| d.insert_temp(label_id(), Label { rect, pass }));
}
/// The label noted just before the field at `field`, on its line.
fn take_label(ui: &Ui, field: Rect) -> Option<Rect> {
let l = ui.data_mut(|d| {
let l = d.get_temp::<Label>(label_id());
d.remove::<Label>(label_id());
l
})?;
let gap = field.left() - l.rect.right();
(l.pass == ui.ctx().cumulative_pass_nr() && (-1.0..=LABEL_GAP).contains(&gap) && field.y_range().contains(l.rect.center().y)).then_some(l.rect)
}
/// Scrub numeric field `field`, its box at `rect`, showing `text`: `value` in its units, rounded
/// to `decimals` places. → The value a drag on the field's label steps it to, or the value it
/// started at when Escape cancels the drag. A click on the label (or a press held without
/// stepping) focuses the field with all of `text` selected. Nothing for a field without a label,
/// disabled or showing no value (objects that differ).
pub(crate) fn field(ui: &Ui, field: Id, rect: Rect, text: &str, value: Option<f64>, decimals: i32) -> Option<f64> {
let label = take_label(ui, rect)?;
let value = value.filter(|v| v.is_finite())?;
if !enabled(ui.ctx()) || !ui.is_enabled() {
return None;
}
// On top of the label, which may sense clicks itself (selectable text).
let resp = ui.interact(label, field.with("scrub-label"), Sense::click_and_drag());
drag(ui, &resp, field, text, value, decimals)
}
/// What a drag on `resp`, the label of field `field`, does this frame.
fn drag(ui: &Ui, resp: &Response, field: Id, text: &str, value: f64, decimals: i32) -> Option<f64> {
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(CursorIcon::ResizeHorizontal);
}
if resp.clicked() {
crate::widgets::focus_field(ui, field, text);
return None;
}
if resp.drag_started() {
// Typing elsewhere ends, as a click elsewhere ends it.
ui.memory_mut(|m| m.stop_text_input());
let s = Scrub { zone: resp.id, start: value, pending: 0.0, step: 0.0, moved: false, cancelled: false };
ui.data_mut(|d| d.insert_temp(state_id(), s));
}
if !(resp.dragged() || resp.drag_stopped()) {
return None;
}
let mut s = ui.data(|d| d.get_temp::<Scrub>(state_id())).filter(|s| s.zone == resp.id && !s.cancelled)?;
// egui ends the drag on Escape: the field goes back to where it started.
let out = if ui.input_mut(|i| i.consume_key(i.modifiers, Key::Escape)) {
s.cancelled = true;
s.moved.then_some(s.start)
} else if resp.drag_stopped() {
// Held without stepping: a slow click.
if !s.moved {
crate::widgets::focus_field(ui, field, text);
}
None
} else {
step(ui, resp, &mut s, value, decimals)
};
ui.data_mut(|d| d.insert_temp(state_id(), s));
out
}
/// The value this frame's drag steps `value` to, if it makes a whole step.
fn step(ui: &Ui, resp: &Response, s: &mut Scrub, value: f64, decimals: i32) -> Option<f64> {
let mods = ui.input(|i| i.modifiers);
let size = if mods.command {
0.1
} else if mods.shift {
10.0
} else {
1.0
};
// A count steps by whole numbers: Ctrl/Cmd makes it slower instead.
let step = f64::max(size, 10f64.powi(-decimals));
if step != s.step {
(s.step, s.pending) = (step, 0.0);
}
s.pending += f64::from(resp.drag_delta().x / PX_PER_STEP) * size;
// (Nudged off the float error of summed tenths.)
let n = (s.pending / step + s.pending.signum() * 1e-9).trunc();
// The drag's first frame only starts it: the app opens its undo group on the next.
if n == 0.0 || resp.drag_started() {
return None;
}
s.pending -= n * step;
s.moved = true;
Some(crate::widgets::round_to(value + n * step, decimals))
}
+154 -165
View File
@@ -521,178 +521,167 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
}
}
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
egui::Window::new(tl!("Keyboard Shortcuts"))
.id(egui::Id::new("dialog-shortcuts"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
.show(ctx, |ui| {
ui.set_width(640.0);
ui.label(egui::RichText::new(tl!("Keyboard Shortcuts")).font(theme::semibold(16.0)).color(t.text));
ui.add_space(10.0);
// Set row.
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Set:")).color(t.text_dim));
let cur = d.str("set");
let mut opts: Vec<&str> = PRESETS.to_vec();
if !PRESETS.contains(&cur.as_str()) {
opts.push(CUSTOM);
}
// The sets listed are ours (translated); a set read from an imported file shows
// its name as it is.
if let Some(&name) = crate::dialogs::mixed_dropdown(ui, "kbset", &cur, &opts, 200.0, |_| true).and_then(|i| opts.get(i))
&& let Some(p) = preset(name)
{
ov = p;
d.fields.insert("set".into(), json!(name));
d.fields.insert("__message".into(), json!(""));
}
ui.add_space(12.0);
if ui.button(tl!("Import…")).clicked() {
d.fields.insert("__import".into(), json!(true));
}
if ui.button(tl!("Export…")).clicked() {
d.fields.insert("__export".into(), json!(true));
}
if ui.button(tl!("Reset to Defaults")).clicked() {
ov.clear();
d.fields.insert("set".into(), json!(PRESETS[0]));
d.fields.insert("__message".into(), json!(tl!("All shortcuts reset to defaults.")));
}
});
ui.add_space(8.0);
// Tabs + search.
ui.horizontal(|ui| {
let tab = d.str("tab");
for (id, label) in [("tools", "Tools"), ("menu", "Menu Commands")] {
if ui.selectable_label(tab == id, egui::RichText::new(tl!(label)).font(theme::semibold(12.5))).clicked() {
d.fields.insert("tab".into(), json!(id));
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut q = d.str("query");
if ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search")).desired_width(200.0)).changed() {
d.fields.insert("query".into(), json!(q));
}
});
});
ui.add_space(6.0);
let tools = d.str("tab") != "menu";
let q = d.str("query").to_lowercase();
let selected = d.str("__selected");
let rec = d.str("__recording");
let scroll_to = d.fields.remove("__scrollTo").and_then(|v| v.as_str().map(str::to_string));
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(egui::vec2(360.0, 16.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(360.0);
ui.label(egui::RichText::new(if tools { tl!("Tool") } else { tl!("Command") }).color(t.text_dim).size(11.0));
});
ui.label(egui::RichText::new(tl!("Shortcut")).color(t.text_dim).size(11.0));
});
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| {
for e in entries().iter().filter(|e| e.is_tool == tools) {
let sc = effective_in(&ov, &e.key);
let matches = e.label.to_lowercase().contains(&q)
|| tl!(&e.label).to_lowercase().contains(&q)
|| e.group.to_lowercase().contains(&q)
|| tl!(&e.group).to_lowercase().contains(&q)
|| sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q));
if !q.is_empty() && !matches {
continue;
}
let row = ui.horizontal(|ui| {
let name = if e.is_tool || e.group.is_empty() {
tl!(&e.label).to_string()
} else {
format!("{} › {}", tl!(&e.group), tl!(&e.label))
};
let r = ui
.allocate_ui_with_layout(egui::vec2(360.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(360.0);
ui.add(egui::Button::selectable(selected == e.key, egui::RichText::new(name).color(t.text)).truncate())
})
.inner;
if r.clicked() {
d.fields.insert("__selected".into(), json!(e.key));
}
let label = if rec == e.key {
tl!("Press keys…").to_string()
} else {
sc.as_deref().map(menus::pretty_shortcut).unwrap_or_else(|| "—".into())
};
let changed = ov.contains_key(&e.key);
let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text });
let b = ui
.add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key))
.on_hover_text(tl!("Click, then press the new shortcut (Esc cancels)"));
if b.clicked() {
d.fields.insert("__recording".into(), json!(e.key));
d.fields.insert("__selected".into(), json!(e.key));
}
});
if scroll_to.as_deref() == Some(e.key.as_str()) {
row.response.scroll_to_me(Some(egui::Align::Center));
}
}
});
});
ui.add_space(6.0);
// Selected-row actions + message.
ui.horizontal(|ui| {
let sel = d.str("__selected");
ui.add_enabled_ui(!sel.is_empty(), |ui| {
if ui.button(tl!("Clear")).on_hover_text(tl!("Remove the shortcut")).clicked() {
let _ = assign(&mut ov, &sel, None, true);
d.fields.insert("set".into(), json!(CUSTOM));
}
if ui.button(tl!("Use Default")).clicked() {
reset_one(&mut ov, &sel);
}
});
let conflict = d.str("__conflict");
if !conflict.is_empty() && ui.button(tl!("Go to Conflict")).clicked() {
let tab = if conflict.starts_with("tool:") { "tools" } else { "menu" };
d.fields.insert("tab".into(), json!(tab));
d.fields.insert("query".into(), json!(""));
d.fields.insert("__selected".into(), json!(conflict));
d.fields.insert("__scrollTo".into(), json!(conflict));
d.fields.insert("__conflict".into(), json!(""));
}
});
let msg = d.str("__message");
if !msg.is_empty() {
ui.label(egui::RichText::new(msg).color(t.accent_strong).size(11.5));
crate::dialogs::modal::show(ctx, tl!("Keyboard Shortcuts"), egui::Id::new("dialog-shortcuts"), -20.0, 20, |ui| {
ui.set_width(640.0);
crate::dialogs::modal::heading(ui, tl!("Keyboard Shortcuts"));
ui.add_space(10.0);
// Set row.
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Set:")).color(t.text_dim));
let cur = d.str("set");
let mut opts: Vec<&str> = PRESETS.to_vec();
if !PRESETS.contains(&cur.as_str()) {
opts.push(CUSTOM);
}
let n = all_conflicts(&ov).len();
if n > 0 {
ui.label(
egui::RichText::new(crate::i18n::tn(
n as u64,
"{n} shortcut is assigned more than once",
"{n} shortcuts are assigned more than once",
))
.color(t.text_dim)
.size(11.0),
);
// The sets listed are ours (translated); a set read from an imported file shows
// its name as it is.
if let Some(&name) = crate::dialogs::mixed_dropdown(ui, "kbset", &cur, &opts, 200.0, |_| true).and_then(|i| opts.get(i))
&& let Some(p) = preset(name)
{
ov = p;
d.fields.insert("set".into(), json!(name));
d.fields.insert("__message".into(), json!(""));
}
ui.add_space(12.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("OK")).clicked() {
ok = true;
if ui.button(tl!("Import…")).clicked() {
d.fields.insert("__import".into(), json!(true));
}
if ui.button(tl!("Export…")).clicked() {
d.fields.insert("__export".into(), json!(true));
}
if ui.button(tl!("Reset to Defaults")).clicked() {
ov.clear();
d.fields.insert("set".into(), json!(PRESETS[0]));
d.fields.insert("__message".into(), json!(tl!("All shortcuts reset to defaults.")));
}
});
ui.add_space(8.0);
// Tabs + search.
ui.horizontal(|ui| {
let tab = d.str("tab");
for (id, label) in [("tools", "Tools"), ("menu", "Menu Commands")] {
if ui.selectable_label(tab == id, egui::RichText::new(tl!(label)).font(theme::semibold(12.5))).clicked() {
d.fields.insert("tab".into(), json!(id));
}
ui.add_space(8.0);
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut q = d.str("query");
if ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search")).desired_width(200.0)).changed() {
d.fields.insert("query".into(), json!(q));
}
});
});
ui.add_space(6.0);
let tools = d.str("tab") != "menu";
let q = d.str("query").to_lowercase();
let selected = d.str("__selected");
let rec = d.str("__recording");
let scroll_to = d.fields.remove("__scrollTo").and_then(|v| v.as_str().map(str::to_string));
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(egui::vec2(360.0, 16.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(360.0);
ui.label(egui::RichText::new(if tools { tl!("Tool") } else { tl!("Command") }).color(t.text_dim).size(11.0));
});
ui.label(egui::RichText::new(tl!("Shortcut")).color(t.text_dim).size(11.0));
});
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| {
for e in entries().iter().filter(|e| e.is_tool == tools) {
let sc = effective_in(&ov, &e.key);
let matches = e.label.to_lowercase().contains(&q)
|| tl!(&e.label).to_lowercase().contains(&q)
|| e.group.to_lowercase().contains(&q)
|| tl!(&e.group).to_lowercase().contains(&q)
|| sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q));
if !q.is_empty() && !matches {
continue;
}
let row = ui.horizontal(|ui| {
let name = if e.is_tool || e.group.is_empty() {
tl!(&e.label).to_string()
} else {
format!("{} › {}", tl!(&e.group), tl!(&e.label))
};
let r = ui
.allocate_ui_with_layout(egui::vec2(360.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(360.0);
ui.add(egui::Button::selectable(selected == e.key, egui::RichText::new(name).color(t.text)).truncate())
})
.inner;
if r.clicked() {
d.fields.insert("__selected".into(), json!(e.key));
}
let label = if rec == e.key {
tl!("Press keys…").to_string()
} else {
sc.as_deref().map(menus::pretty_shortcut).unwrap_or_else(|| "—".into())
};
let changed = ov.contains_key(&e.key);
let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text });
let b = ui
.add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key))
.on_hover_text(tl!("Click, then press the new shortcut (Esc cancels)"));
if b.clicked() {
d.fields.insert("__recording".into(), json!(e.key));
d.fields.insert("__selected".into(), json!(e.key));
}
});
if scroll_to.as_deref() == Some(e.key.as_str()) {
row.response.scroll_to_me(Some(egui::Align::Center));
}
}
});
});
ui.add_space(6.0);
// Selected-row actions + message.
ui.horizontal(|ui| {
let sel = d.str("__selected");
ui.add_enabled_ui(!sel.is_empty(), |ui| {
if ui.button(tl!("Clear")).on_hover_text(tl!("Remove the shortcut")).clicked() {
let _ = assign(&mut ov, &sel, None, true);
d.fields.insert("set".into(), json!(CUSTOM));
}
if ui.button(tl!("Use Default")).clicked() {
reset_one(&mut ov, &sel);
}
});
let conflict = d.str("__conflict");
if !conflict.is_empty() && ui.button(tl!("Go to Conflict")).clicked() {
let tab = if conflict.starts_with("tool:") { "tools" } else { "menu" };
d.fields.insert("tab".into(), json!(tab));
d.fields.insert("query".into(), json!(""));
d.fields.insert("__selected".into(), json!(conflict));
d.fields.insert("__scrollTo".into(), json!(conflict));
d.fields.insert("__conflict".into(), json!(""));
}
});
let msg = d.str("__message");
if !msg.is_empty() {
ui.label(egui::RichText::new(msg).color(t.accent_strong).size(11.5));
}
let n = all_conflicts(&ov).len();
if n > 0 {
ui.label(
egui::RichText::new(crate::i18n::tn(
n as u64,
"{n} shortcut is assigned more than once",
"{n} shortcuts are assigned more than once",
))
.color(t.text_dim)
.size(11.0),
);
}
ui.add_space(12.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("OK")).clicked() {
ok = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
});
});
d.fields.insert("overrides".into(), serde_json::to_value(&ov).unwrap_or(json!({})));
let import = d.fields.remove("__import").is_some();
let export = d.fields.remove("__export").is_some();
+4
View File
@@ -190,6 +190,10 @@ fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) {
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
// Followed before anything returns, so a key typed in a field isn't taken for a paste.
let textless_paste = ctx.input(|i| app.paste_chord.textless_paste(&i.events));
// A numeric field being scrubbed has the keyboard: Escape cancels the drag.
if crate::scrub::phase(ctx) != crate::scrub::Phase::Idle {
return;
}
if app.ui.dialog.is_some() || app.ui.palette_open {
if crate::shortcut_editor::is_recording(app) {
return;
+69
View File
@@ -0,0 +1,69 @@
//! Type → Type on a Path → Type on a Path Options… (#429): the dialog shows the selected type's
//! options (Effect, Flip, Align to Path, Spacing) and OK leaves its brackets where they are.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind, TextKind};
use vectorcraft_engine::Session;
use crate::{VectorcraftApp, dialogs, menus};
/// The start and end brackets of type on a path `id`.
fn brackets(app: &VectorcraftApp, id: NodeId) -> (f64, Option<f64>) {
match app.session.doc().unwrap().doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => match t.kind {
TextKind::OnPath { start, end, .. } => (start, end),
ref k => panic!("not type on a path: {k:?}"),
},
k => panic!("not text: {k:?}"),
}
}
/// Open Type on a Path Options… from its menu item.
fn open_options(app: &mut VectorcraftApp) {
let entry = menus::menu_entries(app).into_iter().find(|e| e.label == "Type on a Path Options…").expect("menu item");
assert_eq!(entry.path, ["Type", "Type on a Path"]);
assert!(entry.enabled);
let (cmd, params) = menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params);
menus::invoke(app, &cmd, params);
}
#[test]
fn type_on_a_path_options_keep_the_type_where_it_is() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
let path = app.run("path.create", json!({"d": "M100 200 L500 200"})).unwrap()["id"].as_u64().unwrap();
let r = app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [300, 210]})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
app.run("select.set", json!({"ids": [id.0]})).unwrap();
let (start, _) = brackets(&app, id);
assert!((start - 0.5).abs() < 1e-3, "{start}");
open_options(&mut app);
let d = app.ui.dialog.as_ref().expect("a dialog opens");
assert_eq!((d.str("__command"), d.str("__label")), ("type.pathOptions".to_string(), "Type on a Path Options".to_string()));
for (k, v) in [("effect", json!("rainbow")), ("flip", json!(false)), ("alignToPath", json!("baseline")), ("spacing", json!(0.0))] {
assert_eq!(d.fields.get(k), Some(&v), "{k}");
}
assert!(!d.fields.contains_key("start") && !d.fields.contains_key("end"), "the brackets aren't the dialog's");
// It draws headlessly: the choices by name.
let text = crate::tests_labels::painted_text(&mut app, |app, ui| dialogs::show(app, ui.ctx()));
for label in ["Type on a Path Options", "Effect:", "Rainbow", "Flip:", "Align to Path:", "Baseline", "Spacing:"] {
assert!(text.contains(label), "{label} in {text}");
}
// OK as it opened: nothing moves.
dialogs::confirm(&mut app).unwrap();
assert_eq!(brackets(&app, id), (start, None));
// Flip and Align to Path: the type turns to the other side of the same stretch of its path.
open_options(&mut app);
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("flip".into(), Value::Bool(true));
d.fields.insert("alignToPath".into(), json!("center"));
dialogs::confirm(&mut app).unwrap();
let (s, e) = brackets(&app, id);
assert!(s.abs() < 1e-9 && e.is_some_and(|e| (e - (1.0 - start)).abs() < 1e-9), "{s} {e:?}");
// The dialog shows the new alignment, and Flip is unchecked again (it flips once more).
open_options(&mut app);
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.fields.get("alignToPath"), d.fields.get("flip")), (Some(&json!("center")), Some(&json!(false))));
}
+282
View File
@@ -0,0 +1,282 @@
//! Scrubbing numeric fields by dragging their label (#400): steps, modifiers, clamps, Escape, the
//! preference, and one undo step in the app.
use std::cell::Cell;
use egui::{CursorIcon, Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2};
use serde_json::json;
use vectorcraft_doc::Unit;
use vectorcraft_engine::Session;
use crate::{VectorcraftApp, scrub, widgets};
/// Headless frames of one labelled field: where its label and its box were last drawn.
struct Row {
ctx: egui::Context,
time: f64,
label: Cell<Rect>,
field: Cell<Rect>,
}
impl Row {
fn new() -> Self {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
Self { ctx, time: 0.0, label: Cell::new(Rect::NOTHING), field: Cell::new(Rect::NOTHING) }
}
/// One frame with `events`, the keyboard holding `modifiers`; `draw` draws the field after
/// the label "W:". → What the field returned and the pointer's cursor.
fn frame(&mut self, events: Vec<Event>, modifiers: Modifiers, draw: &dyn Fn(&mut egui::Ui) -> Option<f64>) -> (Option<f64>, CursorIcon) {
self.time += 0.05;
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0)));
let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect();
let input = egui::RawInput { events, time: Some(self.time), screen_rect, focused: true, ..Default::default() };
let mut got = None;
let mut out = self.ctx.run_ui(input, |ui| {
ui.horizontal(|ui| {
self.label.set(widgets::dim_label(ui, "W:").rect);
let before = ui.cursor().min;
got = draw(ui);
self.field.set(Rect::from_min_max(before, ui.min_rect().max));
});
});
out.textures_delta.clear();
(got, out.platform_output.cursor_icon)
}
/// Drag from `from` by `dx` points in `steps` moves (a frame each) with `modifiers` held, then
/// release (`escape`: press Escape before releasing). → The values the field returned.
fn drag(
&mut self,
from: Pos2,
dx: f32,
steps: usize,
modifiers: Modifiers,
escape: bool,
draw: &dyn Fn(&mut egui::Ui) -> Option<f64>,
) -> Vec<f64> {
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers };
let mut got = vec![];
let mut push = |r: (Option<f64>, CursorIcon)| got.extend(r.0);
push(self.frame(vec![Event::PointerMoved(from)], modifiers, draw));
push(self.frame(vec![button(from, true)], modifiers, draw));
let mut at = from;
for _ in 0..steps {
at.x += dx / steps as f32;
push(self.frame(vec![Event::PointerMoved(at)], modifiers, draw));
}
if escape {
let esc = Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers };
push(self.frame(vec![esc], modifiers, draw));
}
push(self.frame(vec![button(at, false)], modifiers, draw));
push(self.frame(vec![], Modifiers::NONE, draw));
got
}
}
/// A W field in points over `width`, applying what it returns (as the panels do).
fn w_field(width: &Cell<f64>) -> impl Fn(&mut egui::Ui) -> Option<f64> + '_ {
|ui| widgets::num_field(ui, "w", Some(width.get()), Unit::Points, 80.0).inspect(|&v| width.set(v))
}
#[test]
fn dragging_the_label_steps_the_field_with_shift_and_ctrl() {
for (mods, dx, want) in [
// One unit per PX_PER_STEP points, ten with Shift, a tenth with Ctrl/Cmd; left goes down.
(Modifiers::NONE, 40.0, 100.0 + 40.0 / scrub::PX_PER_STEP as f64),
(Modifiers::NONE, -40.0, 100.0 - 40.0 / scrub::PX_PER_STEP as f64),
(Modifiers::SHIFT, 40.0, 100.0 + 400.0 / scrub::PX_PER_STEP as f64),
(Modifiers::COMMAND, 40.0, 100.0 + 4.0 / scrub::PX_PER_STEP as f64),
] {
let mut row = Row::new();
let width = Cell::new(100.0);
let draw = w_field(&width);
row.frame(vec![], Modifiers::NONE, &draw);
let got = row.drag(row.label.get().center(), dx, 4, mods, false, &draw);
assert!((width.get() - want).abs() < 1e-9, "{mods:?} {dx}: {} (values {got:?})", width.get());
assert!(got.len() > 1, "the values come live while dragging: {got:?}");
}
}
#[test]
fn hovering_the_label_shows_the_resize_cursor_and_clicking_it_focuses_the_field() {
let mut row = Row::new();
let width = Cell::new(100.0);
let draw = w_field(&width);
row.frame(vec![], Modifiers::NONE, &draw);
let (_, cursor) = row.frame(vec![Event::PointerMoved(row.label.get().center())], Modifiers::NONE, &draw);
assert_eq!(cursor, CursorIcon::ResizeHorizontal);
// The text box keeps the text cursor.
let (_, cursor) = row.frame(vec![Event::PointerMoved(row.field.get().center())], Modifiers::NONE, &draw);
assert_eq!(cursor, CursorIcon::Text);
// A click on the label (no drag) focuses the field, its text all selected.
let at = row.label.get().center();
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
row.frame(vec![Event::PointerMoved(at), button(true), button(false)], Modifiers::NONE, &draw);
row.frame(vec![], Modifiers::NONE, &draw);
let id = row.ctx.memory(|m| m.focused());
assert!(id.is_some(), "the field has the focus");
let st = egui::TextEdit::load_state(&row.ctx, id.unwrap()).unwrap();
let r = st.cursor.char_range().unwrap().as_sorted_char_range();
assert_eq!((r.start.0, r.end.0), (0, "100 pt".len()));
assert_eq!(width.get(), 100.0);
}
#[test]
fn dragging_the_text_box_does_not_scrub() {
let mut row = Row::new();
let width = Cell::new(100.0);
let draw = w_field(&width);
row.frame(vec![], Modifiers::NONE, &draw);
let got = row.drag(row.field.get().center(), 40.0, 4, Modifiers::NONE, false, &draw);
assert_eq!(width.get(), 100.0, "{got:?}");
}
#[test]
fn a_field_without_a_label_does_not_scrub() {
let mut row = Row::new();
let width = Cell::new(100.0);
// The label is there, but the field is far from it: not its label.
let draw = |ui: &mut egui::Ui| {
ui.add_space(120.0);
widgets::num_field(ui, "w", Some(width.get()), Unit::Points, 80.0).inspect(|&v| width.set(v))
};
row.frame(vec![], Modifiers::NONE, &draw);
let (_, cursor) = row.frame(vec![Event::PointerMoved(row.label.get().center())], Modifiers::NONE, &draw);
assert_ne!(cursor, CursorIcon::ResizeHorizontal);
row.drag(row.label.get().center(), 40.0, 4, Modifiers::NONE, false, &draw);
assert_eq!(width.get(), 100.0);
}
/// The caller's limits hold, and dragging back changes the value at once (no dead zone past the
/// limit); counts step by whole numbers, Ctrl/Cmd only slows them.
#[test]
fn scrubbing_respects_limits_and_precision() {
let mut row = Row::new();
let opacity = Cell::new(95.0);
let draw = |ui: &mut egui::Ui| widgets::plain_field(ui, "o", opacity.get(), "%", 0, 60.0).inspect(|&v| opacity.set(v.clamp(0.0, 100.0)));
row.frame(vec![], Modifiers::NONE, &draw);
let at = row.label.get().center();
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
row.frame(vec![Event::PointerMoved(at), button(at, true)], Modifiers::NONE, &draw);
for x in [10.0, 20.0, 30.0, 40.0] {
row.frame(vec![Event::PointerMoved(at + vec2(x, 0.0))], Modifiers::NONE, &draw);
}
assert_eq!(opacity.get(), 100.0, "clamped");
row.frame(vec![Event::PointerMoved(at + vec2(36.0, 0.0))], Modifiers::NONE, &draw);
assert_eq!(opacity.get(), 98.0, "back down at once");
row.frame(vec![button(at + vec2(36.0, 0.0), false)], Modifiers::NONE, &draw);
let mut row = Row::new();
let count = Cell::new(3.0);
let draw = |ui: &mut egui::Ui| widgets::plain_field(ui, "n", count.get(), "", 0, 60.0).inspect(|&v| count.set(v));
row.frame(vec![], Modifiers::NONE, &draw);
let got = row.drag(row.label.get().center(), 40.0, 4, Modifiers::COMMAND, false, &draw);
assert_eq!(count.get(), 5.0, "{got:?}");
assert!(got.iter().all(|v| v.fract() == 0.0), "{got:?}");
// Units: a millimetre field steps whole millimetres.
let mut row = Row::new();
let pt = Cell::new(Unit::Millimeters.to_pt(10.0));
let draw = |ui: &mut egui::Ui| widgets::num_field(ui, "mm", Some(pt.get()), Unit::Millimeters, 80.0).inspect(|&v| pt.set(v));
row.frame(vec![], Modifiers::NONE, &draw);
row.drag(row.label.get().center(), 20.0, 2, Modifiers::NONE, false, &draw);
assert!((Unit::Millimeters.from_pt(pt.get()) - 20.0).abs() < 1e-9, "{}", Unit::Millimeters.from_pt(pt.get()));
}
#[test]
fn escape_puts_the_value_back_and_the_preference_turns_scrubbing_off() {
let mut row = Row::new();
let width = Cell::new(100.0);
let draw = w_field(&width);
row.frame(vec![], Modifiers::NONE, &draw);
let got = row.drag(row.label.get().center(), 40.0, 4, Modifiers::NONE, true, &draw);
assert_eq!(width.get(), 100.0, "{got:?}");
assert!(got.len() > 1 && got.last() == Some(&100.0), "{got:?}");
assert_eq!(scrub::phase(&row.ctx), scrub::Phase::Idle);
scrub::set_enabled(&row.ctx, false);
let (_, cursor) = row.frame(vec![Event::PointerMoved(row.label.get().center())], Modifiers::NONE, &draw);
assert_ne!(cursor, CursorIcon::ResizeHorizontal);
row.drag(row.label.get().center(), 40.0, 4, Modifiers::NONE, false, &draw);
assert_eq!(width.get(), 100.0);
}
// ---------- in the app ----------
/// One frame of the whole window with `events`. → The rect of the first text drawn as `text`.
fn app_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>, text: &str) -> Option<Rect> {
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 900.0)));
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
fn find(shape: &egui::Shape, text: &str) -> Option<Rect> {
match shape {
egui::Shape::Text(t) if t.galley.text() == text => Some(t.galley.rect.translate(t.pos.to_vec2())),
egui::Shape::Vec(v) => v.iter().find_map(|s| find(s, text)),
_ => None,
}
}
out.shapes.iter().find_map(|s| find(&s.shape, text))
}
fn app_with_rect() -> (VectorcraftApp, egui::Context) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
(app, egui::Context::default())
}
fn width(app: &VectorcraftApp) -> f64 {
app.session.transform_box(&app.session.doc().unwrap().selection.objects).unwrap().rect.width()
}
fn undo_len(app: &VectorcraftApp) -> usize {
app.session.doc().unwrap().history.undo.len()
}
/// Drag the W label by `dx` in four moves (`escape`: Escape before the release).
fn drag_w(app: &mut VectorcraftApp, ctx: &egui::Context, dx: f32, escape: bool) {
let mut w = None;
for _ in 0..4 {
w = app_frame(app, ctx, vec![], "W:");
}
let at = w.expect("the W label is shown").center();
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
app_frame(app, ctx, vec![Event::PointerMoved(at)], "");
app_frame(app, ctx, vec![button(at, true)], "");
for i in 1..=4 {
app_frame(app, ctx, vec![Event::PointerMoved(at + vec2(dx * i as f32 / 4.0, 0.0))], "");
}
if escape {
app_frame(app, ctx, vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], "");
}
app_frame(app, ctx, vec![button(at + vec2(dx, 0.0), false)], "");
app_frame(app, ctx, vec![], "");
}
/// A scrub of the W label resizes the selection live and is one undo step; Escape takes it back
/// without a step (and without deselecting); with the preference off the label doesn't scrub.
#[test]
fn scrubbing_w_in_the_app_is_one_undo_step() {
let (mut app, ctx) = app_with_rect();
let undo = undo_len(&app);
drag_w(&mut app, &ctx, 40.0, false);
assert!((width(&app) - (100.0 + 40.0 / scrub::PX_PER_STEP as f64)).abs() < 1e-6, "{}", width(&app));
assert_eq!(undo_len(&app), undo + 1, "one undo step for the drag");
app.run("edit.undo", json!({})).unwrap();
assert!((width(&app) - 100.0).abs() < 1e-6);
drag_w(&mut app, &ctx, 40.0, true);
assert!((width(&app) - 100.0).abs() < 1e-6, "Escape: {}", width(&app));
assert_eq!(undo_len(&app), undo, "no step");
assert!(!app.session.doc().unwrap().selection.objects.is_empty(), "Escape didn't deselect");
app.run("prefs.set", json!({"key": "scrubNumericFields", "value": false})).unwrap();
drag_w(&mut app, &ctx, 40.0, false);
assert!((width(&app) - 100.0).abs() < 1e-6, "off: {}", width(&app));
}
+92 -29
View File
@@ -4,8 +4,8 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKin
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::Unit;
use crate::icons;
use crate::theme::{self, Tokens};
use crate::{icons, scrub};
/// Square icon button; `selected` draws the pressed well.
pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32) -> Response {
@@ -87,8 +87,20 @@ pub fn divider(ui: &mut Ui) {
ui.add_space(6.0);
}
/// A label in the panels' text colour; before a numeric field it scrubs the field
/// ([`scrub`]).
pub fn dim_label(ui: &mut Ui, text: &str) -> Response {
dim_name(ui, tl!(text))
let resp = dim_name(ui, tl!(text));
scrub::note_label(ui, resp.rect);
resp
}
/// A field's label in other styles (`text` as given): before a numeric field it scrubs the field
/// ([`scrub`]).
pub fn field_label(ui: &mut Ui, text: impl Into<egui::WidgetText>) -> Response {
let resp = ui.label(text);
scrub::note_label(ui, resp.rect);
resp
}
/// [`dim_label`] for a name that is user or file data (a swatch, style, artboard or font name):
@@ -110,10 +122,10 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
buf = shown.clone();
}
take_dialog_focus(ui, id, &buf);
let resp = ui
let (rect, resp) = ui
.allocate_ui_with_layout(vec2(width, 26.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(width);
egui::Frame::NONE
let framed = egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
.corner_radius(CornerRadius::same(2))
@@ -128,16 +140,26 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
.font(egui::FontId::proportional(12.5))
.text_color(t.text_strong),
)
})
.inner
});
(framed.response.rect, framed.inner)
})
.inner;
select_all_on_focus(ui, &resp, &buf);
// ↑/↓ step in the field's unit.
// ↑/↓ step in the field's unit; a drag on its label scrubs it.
let stepped = step_with_arrows(ui, &resp, &mut buf, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.format(unit.to_pt(v)));
let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS);
let commit = resp.lost_focus() && buf != shown;
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
if commit { unit.parse(&buf) } else { stepped.map(|v| unit.to_pt(v)) }
if commit { unit.parse(&buf) } else { stepped.or(scrubbed).map(|v| unit.to_pt(v)) }
}
/// The places a stepped value in a unit field keeps (no 12.300000001).
const STEP_DECIMALS: i32 = 3;
/// `v` rounded to `decimals` places.
pub(crate) fn round_to(v: f64, decimals: i32) -> f64 {
let scale = 10f64.powi(decimals.clamp(0, 6));
(v * scale).round() / scale
}
/// ↑/↓ in focused numeric field `resp` step the number `buf` shows (`read` parses it, `show`
@@ -163,8 +185,8 @@ fn step_with_arrows(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&s
if steps == 0.0 {
return None;
}
// Rounded as fields show it (no 12.300000001).
let v = ((read(buf)? + steps) * 1000.0).round() / 1000.0;
// Rounded as fields show it.
let v = round_to(read(buf)? + steps, STEP_DECIMALS);
*buf = show(v);
ui.data_mut(|d| d.insert_temp(resp.id, buf.clone()));
select_all(ui, resp.id, buf);
@@ -184,6 +206,11 @@ pub(crate) fn take_dialog_focus(ui: &Ui, id: egui::Id, text: &str) {
if ui.is_sizing_pass() || ui.data_mut(|d| d.remove_temp::<bool>(dialog_focus_flag())).is_none() {
return;
}
focus_field(ui, id, text);
}
/// Give text field `id` the keyboard focus with all of `text` selected.
pub(crate) fn focus_field(ui: &Ui, id: egui::Id, text: &str) {
select_all(ui, id, text);
ui.memory_mut(|m| m.request_focus(id));
}
@@ -209,16 +236,17 @@ fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) {
}
/// The recessed text box of the panel fields showing `shown`, `rows` lines tall (1: single line),
/// its text kept under `id` while it has focus. Returns the text (as edited) and the response.
fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, Response) {
/// its text kept under `id` while it has focus. Returns the text (as edited), the text edit's
/// response and the box.
fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, Response, Rect) {
let t = Tokens::get(ui.ctx());
let editing = ui.memory(|m| m.has_focus(id));
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.to_string()) } else { shown.to_string() };
let height = 26.0 + 16.0 * (rows.max(1) - 1) as f32;
let resp = ui
let (rect, resp) = ui
.allocate_ui_with_layout(vec2(width, height), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(width);
egui::Frame::NONE
let framed = egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
.corner_radius(CornerRadius::same(2))
@@ -233,12 +261,12 @@ fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize
.font(egui::FontId::proportional(12.5))
.text_color(t.text_strong),
)
})
.inner
});
(framed.response.rect, framed.inner)
})
.inner;
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
(buf, resp)
(buf, resp, rect)
}
/// A recessed text field showing `value` (blank when `None`: the selection's values differ),
@@ -247,7 +275,7 @@ fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize
pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<&str>, width: f32, rows: usize) -> Option<String> {
let id = ui.id().with(id);
let shown = value.unwrap_or_default();
let (buf, resp) = recessed_text(ui, id, shown, width, rows);
let (buf, resp, _) = recessed_text(ui, id, shown, width, rows);
(resp.lost_focus() && buf.trim() != shown).then(|| buf.trim().to_string())
}
@@ -257,6 +285,7 @@ pub fn label_row(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&m
let (r, _) = ui.allocate_exact_size(vec2(label_width, 24.0), Sense::hover());
let t = Tokens::get(ui.ctx());
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, tl!(label), egui::FontId::proportional(12.5), t.text);
scrub::note_label(ui, r);
add(ui);
});
}
@@ -288,14 +317,14 @@ pub fn mixed_field(
vectorcraft_doc::parse_number(&bare.replace(['%', '°'], ""))
};
let shown = value.map(show).unwrap_or_default();
let (mut buf, resp) = recessed_text(ui, id, &shown, width, 1);
let (mut buf, resp, rect) = recessed_text(ui, id, &shown, width, 1);
select_all_on_focus(ui, &resp, &buf);
// ↑/↓ step at the field's precision (a count ignores Ctrl/Cmd's tenth).
let stepped = step_with_arrows(ui, &resp, &mut buf, read, show).map(|v| {
let scale = 10f64.powi(decimals.min(6) as i32);
(v * scale).round() / scale
});
if resp.lost_focus() && buf != shown { read(&buf) } else { stepped.filter(|&v| Some(v) != value) }
// ↑/↓ step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on its label
// scrubs it.
let decimals = decimals.min(6) as i32;
let stepped = step_with_arrows(ui, &resp, &mut buf, read, show).map(|v| round_to(v, decimals));
let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals);
if resp.lost_focus() && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) }
}
/// Draw a paint preview (swatch chip) into `rect`.
@@ -530,6 +559,39 @@ pub(crate) fn combo<R>(
.inner
}
/// A popover anchored to `resp`, opened and closed by `toggle` (usually `resp.clicked()`), and
/// closed by Escape, a click outside it and outside any popup it opened, or a frame in which its
/// anchor isn't shown. Use it instead of `egui::Popup::menu` when the popover holds dropdowns or
/// popups of its own: egui remembers one open popup at a time, so opening a dropdown inside a
/// remembered popup would close the popup (and the dropdown with it). This one keeps its open
/// state itself: the last frame it was open in.
pub fn popover<R>(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) -> R) -> Option<R> {
let ctx = &resp.ctx;
let id = resp.id.with("popover");
let frame = ctx.cumulative_frame_nr();
let was_open = ctx.data(|d| d.get_temp::<u64>(id)).is_some_and(|f| f + 1 >= frame);
let mut open = if toggle {
!was_open
} else {
// Popups (this one and those it opened) are on foreground layers; a click anywhere
// else closes it.
let clicked_elsewhere = ctx
.input(|i| i.pointer.any_click().then(|| i.pointer.interact_pos()).flatten())
.is_some_and(|pos| ctx.layer_id_at(pos).is_none_or(|l| l.order != egui::Order::Foreground));
was_open && !clicked_elsewhere
};
let inner =
egui::Popup::menu(resp).id(id).open_bool(&mut open).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(content).map(|r| r.inner);
ctx.data_mut(|d| {
if open {
d.insert_temp(id, frame);
} else {
d.remove::<u64>(id);
}
});
inner
}
/// The body of a menu or popup list: as tall as its items up to the bottom of the window, and
/// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on
/// small windows.
@@ -1102,7 +1164,7 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
let editing = ui.memory(|m| m.has_focus(id));
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() };
let enabled = ui.is_enabled();
let resp = egui::Frame::NONE
let framed = egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
.corner_radius(CornerRadius::same(2))
@@ -1116,15 +1178,16 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
.font(egui::FontId::proportional(12.5))
.text_color(if enabled { t.text_strong } else { t.text_disabled }),
)
})
.inner;
});
let resp = framed.inner;
select_all_on_focus(ui, &resp, &buf);
let scrubbed = scrub::field(ui, id, framed.response.rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS);
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
if resp.lost_focus() && buf != shown {
let s = buf.trim();
if s.is_empty() { Some(None) } else { unit.parse(s).map(Some) }
} else {
None
scrubbed.map(|v| Some(unit.to_pt(v)))
}
}
+56 -69
View File
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::state::{Dialog, DockTab, UiState};
use crate::theme::{self, Tokens};
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -318,79 +318,66 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let (mut ok, mut cancel) = (false, false);
let mut action: Option<(&str, Value)> = None;
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
let manage = d.kind == "manageWorkspaces";
egui::Window::new("Workspace")
.id(egui::Id::new("dialog-workspace"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22)))
.show(ctx, |ui| {
ui.set_width(340.0);
ui.label(
egui::RichText::new(if manage { tl!("Manage Workspaces") } else { tl!("New Workspace") }).font(theme::semibold(16.0)).color(t.text),
);
ui.add_space(12.0);
if manage {
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.set_min_height(140.0);
ui.set_width(ui.available_width());
for n in names(&app.ui) {
let builtin = is_builtin(&n);
let shown = crate::panels::label_or_name(&n, builtin);
let text = if builtin { egui::RichText::new(shown).color(t.text_dim) } else { egui::RichText::new(shown).color(t.text) };
if ui.selectable_label(d.str("selected") == n, text).clicked() && !builtin {
d.fields.insert("selected".into(), json!(n));
d.fields.insert("name".into(), json!(n));
}
crate::dialogs::modal::show(ctx, "Workspace", egui::Id::new("dialog-workspace"), -40.0, 22, |ui| {
ui.set_width(340.0);
crate::dialogs::modal::heading(ui, if manage { tl!("Manage Workspaces") } else { tl!("New Workspace") });
ui.add_space(12.0);
if manage {
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.set_min_height(140.0);
ui.set_width(ui.available_width());
for n in names(&app.ui) {
let builtin = is_builtin(&n);
let shown = crate::panels::label_or_name(&n, builtin);
let text = if builtin { egui::RichText::new(shown).color(t.text_dim) } else { egui::RichText::new(shown).color(t.text) };
if ui.selectable_label(d.str("selected") == n, text).clicked() && !builtin {
d.fields.insert("selected".into(), json!(n));
d.fields.insert("name".into(), json!(n));
}
});
ui.add_space(8.0);
let sel = d.str("selected");
ui.horizontal(|ui| {
let mut name = d.str("name");
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(180.0)).changed() {
d.fields.insert("name".into(), json!(name));
}
if ui.button(tl!("New")).on_hover_text(tl!("Save the current layout under this name")).clicked() {
action = Some(("window.workspace.new", json!({"name": d.str("name")})));
}
ui.add_enabled_ui(!sel.is_empty(), |ui| {
if ui.button(tl!("Rename")).clicked() {
action = Some(("window.workspace.rename", json!({"name": sel, "to": d.str("name")})));
}
if ui.button(tl!("Delete")).clicked() {
action = Some(("window.workspace.delete", json!({"name": sel})));
}
});
});
} else {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim));
let mut name = d.str("name");
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0));
if r.changed() {
d.fields.insert("name".into(), json!(name));
}
});
ui.label(egui::RichText::new(tl!("Saves the current bars, toolbar and panel layout.")).color(t.text_dim).size(11.0));
}
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("OK")).clicked() {
ok = true;
}
ui.add_space(8.0);
if !manage && widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
});
ui.add_space(8.0);
let sel = d.str("selected");
ui.horizontal(|ui| {
let mut name = d.str("name");
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(180.0)).changed() {
d.fields.insert("name".into(), json!(name));
}
if ui.button(tl!("New")).on_hover_text(tl!("Save the current layout under this name")).clicked() {
action = Some(("window.workspace.new", json!({"name": d.str("name")})));
}
ui.add_enabled_ui(!sel.is_empty(), |ui| {
if ui.button(tl!("Rename")).clicked() {
action = Some(("window.workspace.rename", json!({"name": sel, "to": d.str("name")})));
}
if ui.button(tl!("Delete")).clicked() {
action = Some(("window.workspace.delete", json!({"name": sel})));
}
});
});
} else {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim));
let mut name = d.str("name");
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0));
if r.changed() {
d.fields.insert("name".into(), json!(name));
}
});
ui.label(egui::RichText::new(tl!("Saves the current bars, toolbar and panel layout.")).color(t.text_dim).size(11.0));
}
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("OK")).clicked() {
ok = true;
}
ui.add_space(8.0);
if !manage && widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
});
});
if !manage && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
ok = true;
}
+30 -4
View File
@@ -269,8 +269,12 @@ without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOp
with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. A document saved as SVG or PDF
remembers its options, so the next save reuses them. A PDF opened with `pages`, `cropTo` or `password` is only part
of the file, so Save asks for a new name instead of writing it back.
`file.documentColorMode {mode, convert?, intent?}` switches the document colour mode, converting its colours
through the colour settings (`object.convertDocumentColorMode` is an alias kept for older scripts).
`file.documentColorMode {mode, convert?, intent?, grays?}` switches the document colour mode, converting the colours of
the art, symbols, pattern tiles and swatches through the colour settings (`object.convertDocumentColorMode` is an
alias kept for older scripts). Gray colours stay Gray and print on the black plate only. RGB greys are colours like any
other: to CMYK they separate through the profile into four-colour greys and a rich black, as Illustrator converts
them. `grays: "black"` puts RGB greys with R = G = B on the black plate instead, K = their grey value (the inks Edit Colors ›
Convert to Grayscale before the switch would give); other colours convert as usual.
SVG import keeps what the canvas edits live. `<pattern>` becomes a pattern swatch. A `<symbol>` with `<use>` becomes a
symbol (named after its `data-name`, else its id) with an instance per `<use>`; a `<use>` that shows it differently
@@ -370,7 +374,8 @@ and Separation and DeviceN inks become spot swatches the art links to at its tin
(DeviceCMYK, or ICC-based with four components) keep their ink amounts: a CMYK JPEG as it is, any other as a CMYK TIFF
(masked CMYK images, and ones with 1, 2 or 4 bits per sample, open in RGB with a warning). Placing a CMYK TIFF keeps it
CMYK too.
`colorMode: "rgb" | "cmyk"` opens any file in that mode instead, its colours converted as `file.documentColorMode` does:
`colorMode: "rgb" | "cmyk"` opens any file in that mode instead, its colours converted as `file.documentColorMode` does
(`grays` too):
```json
{"name":"run_command","arguments":{"command":"document.pdfInfo","params":{"path":"/tmp/brochure.pdf","thumbnail":2,"cropTo":"trim"}}}
@@ -1142,7 +1147,9 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
The preferences for the view need the desktop app (`vectorcraft-cli mcp --connect`): `zoomWithMouseWheel` (the
wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; the control channel's
`ui.wheel` turns the wheel), `zoomToSelection` (on: Zoom In and Zoom Out centre the selection), `showToolTips`,
`anchorSize` (1–7), `gridColor`, `gridStyle`, `gridsInBack`, `guideColor`, `guideStyle` and `recentFontsCount`.
`anchorSize` (1–7), `gridColor`, `gridStyle`, `gridsInBack`, `guideColor`, `guideStyle`, `recentFontsCount` and
`scrubNumericFields` (on: a horizontal drag on a numeric field's label steps the field, one undo step per drag; the
control channel's `ui.drag` scrubs).
## Type preferences
@@ -2022,6 +2029,25 @@ undo step. Object › Transform › Scale, the Scale tool and the Transform pane
{"name":"run_command","arguments":{"command":"text.reshapeArea","params":{"id":42,"anchors":[[0,2]],"dx":40,"dy":60}}}
```
## Moving and flipping type on a path
Type on a path flows between a start and an end bracket, stored as fractions of its path's length.
`type.pathOptions {start?, end?, flip?, effect?, alignToPath?, spacing?, ids?}` sets them and the rest of Type on a
Path Options: `end: null` puts the end bracket back at the end of the path (or once round a closed one); `flip`
then turns the type to the other side of its path, the path running the other way and the brackets swapping ends so
the type keeps its stretch of the path; `alignToPath` runs the `ascender`, `descender`, `center` or `baseline` (the
default) along the path; `spacing` (points) closes glyphs up round the outside of curves and opens them up round the
inside. With none of them it queries the first selected type on a path:
`{start, end, flip: false, effect, alignToPath, spacing}`. The Options dialog shows Effect, Flip, Align to Path and
Spacing and leaves the brackets where they are. As in the reference app, the Selection and Direct Selection tools
show selected type on a path's brackets: dragging the start or end bracket (`pointer_gesture`) sets where the type
begins or ends, dragging the centre bracket slides the type along its path and, dragged across the path, flips it
(with Cmd/Ctrl held it only slides). Each is one undo step.
```json
{"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}}
```
## Constrain proportions
The link between W and H in the Transform panel, the Properties panel and the Control bar is the