Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 17f66003cf Fix #439: Shape Builder erase mode deletes edges, shows what will go and follows Alt live
Alt already deleted regions and pieces of open lines, but erase mode had
no edges of closed shapes, no distinct pointer, highlighted regions in the
merge-mode grey, only noticed Alt on a mouse move, and an erase drag could
flip mode at release. Like the reference app's erase mode:

- pathops::shape_builder(shapes, edges) returns a BuilderArrangement; with
  `edges` it also cuts the outlines of closed (and filled open) paths
  wherever other paths meet them. pathops::cut_out removes such a piece
  from a path: a closed subpath opens there, an open one splits or
  shortens, a piece all the way round takes its subpath, and a filled open
  path's invisible closing edge never comes back as a visible segment.
  Specks where paths only touch cut nothing; a zero-length input is no
  longer the source of edges running through it. encloses_area is shared
  with the tool's closing of filled open paths.
- BuilderMap (with_edges) hit-tests edges like line pieces (nearest wins);
  an erase gesture that crosses an edge into a region deletes the region,
  not the edge (erased_edges).
- shapeBuilder.merge {erase}: deletes the edges touched within tolerance
  (from every shape along them), after the regions; returns `edges`.
  shapeBuilder.regions lists `edges` (bounds, source). One undo step.
- Shape Builder tool: Alt held switches to erase mode and back on release:
  red mesh/edge highlights of what will be deleted, a minus pointer
  (Cursor::ShapeBuilder / ShapeBuilderErase, a plus in merge mode); a drag
  keeps the mode it started in. The canvas re-hovers the tool when a
  modifier changes, so the highlight follows Alt without a mouse move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:37:35 +07:00
BFlat 1b93336bdf Merge pull request #437 from bobtherobot/feat/floating-flyouts
Toolbar: tool flyouts tear off into floating toolbars
2026-10-08 13:04:27 +07:00
BFlat 9a4a632ea7 Merge pull request #436 from bobtherobot/fix/toolbar-flyouts
Toolbar: a right press or a corner press opens a tool group's flyout at once
2026-10-08 13:04:11 +07:00
bflatasticandClaude Opus 5.5 97906f2104 Toolbar: a click on the tear-off bar floats the group, window.floatTools, strips in workspaces (review of #437)
- A click on a flyout's tear-off bar, or the release of the long press
  that opened it over the bar, tears the flyout off too, as in
  Illustrator; dragging it still does.
- window.floatTools {tool, floating?} floats or puts back the group
  holding a tool, for the control channel and MCP's invoke_menu.
- Floating strips are part of user workspaces; the built-in ones float
  none, so Reset puts them back.
- Loaded preferences keep one strip per group, of known tools.
- No per-frame clone of the strips or copy of the active tool's id.
- docs/control-protocol.md, ROADMAP, Float Tool Group translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:01:40 +07:00
bflatasticandClaude Opus 5.5 b08188e5a5 Toolbar: only tool groups' buttons check for a flyout press, ROADMAP (review of #436)
A press on a single tool's button no longer schedules the repaint that
would open a flyout it doesn't have. The ROADMAP's UI list and Last
updated line name the right and corner press.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:01:25 +07:00
Michael GiesonandClaude Opus 5.5 dcc8a24f66 Toolbar: tool flyouts tear off into floating toolbars
A grab bar runs down the right side of a tool group's flyout. Dragging it
tears the flyout off: the group floats as a strip of its tools' buttons
(the active one lit, each tool's name and shortcut in its tool tip), with
the bar on its right. The same bar moves the strip, and the × at the top
of the bar puts the group back in the toolbar. While a group floats, the
presses that would open its flyout (right, long or corner press) raise
the strip and flash its border instead, so a group is never both a
flyout and a floating strip. Floating strips are kept with the UI
preferences and kept on screen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:01:25 +07:00
Michael GiesonandClaude Opus 5.5 e80b3543d3 Toolbar: a right press or a corner press opens a tool group's flyout at once
A right press opens the flyout on the press instead of on the release,
including on the active tool's button, and the release leaves it open. A
primary press on the button's corner triangle opens it at once without
picking the tool; a plain click in the middle still picks it. The long
press keeps scheduling the frame where it becomes long.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:01:13 +07:00
BFlat a6fceb295e Merge pull request #435 from bobtherobot/feat/layers-panel
Layers: drag ghost, drops on the bottom-bar buttons, trash deletes again
2026-10-08 13:01:07 +07:00
bflatasticandClaude Opus 5.5 a32aed7e81 Layers: only the trash and New Layer take dropped rows (review of #435)
Illustrator documents two drop targets in the Layers panel's bottom bar:
dragging items to Create New Layer duplicates them, to the Delete icon
deletes them. Dropping rows on New Sublayer or Make/Release Clipping
Mask no longer creates a sublayer or clips the row's layer; those
buttons only act on a click, and the new test checks a drop on New
Sublayer changes nothing. The trash and New Layer share one drop loop,
and the drag ghost paints its colour bar and thumbnail with the row's
own helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:58:21 +07:00
Michael GiesonandClaude Opus 5.5 a216c36779 Layers: drag ghost, drops on the bottom-bar buttons, trash deletes again
Dragging a row shows a dimmed copy of it (colour bar, thumbnail, name)
under the pointer. A row dropped on Delete, New Layer, New Sublayer or
Make/Release Clipping Mask runs that button on the row's layer, as a
click would: Delete removes the row itself and New Layer duplicates it,
as in Illustrator.

Rows dragged onto the trash weren't deleted. The trash took the drop as
a target-circle payload first, and egui's take discards a payload of
another type, so the rows reached the second check as nothing. It now
peeks at the type before taking. The trash is also outlined while rows
or a target circle are over it, like the other drop buttons.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:58:04 +07:00
Michael GiesonandClaude Opus 5.5 80f4de1a5e Layers: Layer Options' Color list shows a chip of each colour
Each preset in the Color dropdown has a chip of its colour before its
name; Custom has none, its name aligned with the others.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:58:04 +07:00
BFlat cae68a57a1 Merge pull request #438 from storytold/fix/issue-429
Fix #429: Type on a path brackets, flip and Type on a Path Options that keep the type in place
2026-10-08 12:57:55 +07:00
22 changed files with 1466 additions and 198 deletions

No files matched your search

+6 -5
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), 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))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -95,8 +95,9 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
- Actions panel that records and plays back commands.
- **UI:** Illustrator 2026 layout restyled to measured values:
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese and Spanish ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
@@ -126,7 +127,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies, the trash deletes), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles: the dragged edges align with the other objects; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers (Shift snaps them to the ruler's ticks) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the art's edges, centres and anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it.
- **Geometry and effects:**
@@ -135,7 +136,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **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).
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. 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}`).
@@ -196,7 +197,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on plain paths and polygons (and Relative rounding) | 10–15 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection and options | 3–6 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
+44 -18
View File
@@ -16,7 +16,7 @@ use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{FillRule, PathData, Point, Rect};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{
self as b, BuilderMap, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, Touched, edge_near, face_at, is_live_paint, sample_polyline,
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder",
[],
None,
"{ids?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (Alt-drag deletes the regions, and the pieces of unfilled open paths within tolerance), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines}",
"{ids?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of unfilled open paths and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
has_selection_or_ids,
sb_merge
),
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder Regions",
[],
None,
"{ids?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose) and the pieces of those open paths",
"{ids?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
has_selection_or_ids,
sb_regions
),
@@ -225,14 +225,14 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "points must be a non-empty list of finite [x,y]"));
}
let erase = bool_or(p, "erase", false);
// Line pieces are only deleted (Alt), never merged.
let line_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
// Line pieces and edges are only deleted (erase mode), never merged.
let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
let fill = fill_param(s, p, C)?;
let current_fill = s.paint.fill.clone();
let roots = root_ids(s, p)?;
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
let (ids, merged, regions, lines) = s.edit(label, |d, sel| {
let (map, leaves) = BuilderMap::new(d, &roots);
let (ids, merged, touched) = s.edit(label, |d, sel| {
let (map, leaves) = BuilderMap::new(d, &roots, erase);
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
if map.shapes.is_empty() {
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
@@ -240,11 +240,15 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25);
let mut touched = Touched::default();
for q in sample_polyline(&pts, step) {
touched.add(map.hit(q, line_tol));
touched.add(map.hit(q, piece_tol));
}
touched.edges = map.erased_edges(&touched);
if touched.is_empty() {
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
}
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
// Edges come back from the planar map within a few hundredths of the outlines they follow.
let cut_tol = step * 0.2;
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
let union = if picked.is_empty() { PathData::default() } else { po::merge_regions(&picked) };
// The merged shape takes the look of the front-most object over the first region, or (for an
@@ -269,11 +273,26 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
} else if !picked.iter().any(|r| r.sources.contains(&i)) {
new_nodes.push(d.reid(leaf));
} else {
let rest = po::boolean(&sh.path, sh.rule, &union, FillRule::NonZero, po::BoolOp::Difference);
if !rest.is_empty() {
// A shape loses the regions deleted or merged, then the edges deleted (each one on
// its outline, shared edges from every shape along them).
let in_union = picked.iter().any(|r| r.sources.contains(&i));
let mut rest = if in_union {
po::boolean(&sh.path, sh.rule, &union, FillRule::NonZero, po::BoolOp::Difference)
} else {
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
};
let fill_closes = !leaf.appearance.fill_paint().is_none();
let mut cut = false;
for piece in &cuts {
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
rest = r;
cut = true;
}
}
if !in_union && !cut {
new_nodes.push(d.reid(leaf));
} else if !rest.is_empty() {
new_nodes.push(shape_node(d, rest, Some(leaf)));
}
}
@@ -288,9 +307,15 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
}
let new_ids = replace_roots(d, &roots, new_nodes)?;
sel.set(new_ids.iter().copied());
Ok((new_ids, merged_id, touched.regions.len(), touched.lines.len()))
Ok((new_ids, merged_id, touched))
})?;
Ok(json!({ "ids": ids_json(&ids), "merged": merged.map(|m| m.0), "regions": regions, "lines": lines }))
Ok(json!({
"ids": ids_json(&ids),
"merged": merged.map(|m| m.0),
"regions": touched.regions.len(),
"lines": touched.lines.len(),
"edges": touched.edges.len(),
}))
}
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
@@ -315,7 +340,7 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
let roots = root_ids(s, p)?;
let d = &s.doc()?.doc;
let (map, leaves) = BuilderMap::new(d, &roots);
let (map, leaves) = BuilderMap::new(d, &roots, true);
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
let regions: Vec<Value> = map
.regions
@@ -328,9 +353,10 @@ fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
})
})
.collect();
let lines: Vec<Value> =
map.lines.iter().map(|(l, bb)| json!({ "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], "source": id_of(l.key as usize) })).collect();
Ok(json!({ "regions": regions, "lines": lines }))
let pieces = |list: &[(po::Shape, Rect)]| -> Vec<Value> {
list.iter().map(|(l, bb)| json!({ "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], "source": id_of(l.key as usize) })).collect()
};
Ok(json!({ "regions": regions, "lines": pieces(&map.lines), "edges": pieces(&map.edges) }))
}
// ---------- Live Paint ----------
+63
View File
@@ -320,6 +320,69 @@ fn shape_builder_tool_alt_deletes_line_pieces() {
assert_eq!(layer_children(&s), 3, "square + the line's two loose ends");
}
/// Red square A (0..100) and blue square B (50..150 both ways), overlapping at a corner, selected.
fn corner(s: &mut Session) -> (NodeId, NodeId) {
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
let b = rect(s, 50.0, 50.0, 100.0, 100.0);
set_fill(s, a, Color::rgb(1.0, 0.0, 0.0));
set_fill(s, b, Color::rgb(0.0, 0.0, 1.0));
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
(a, b)
}
/// (closed, anchors, length) of each of `ids`.
fn shapes_of(s: &Session, ids: &[NodeId]) -> Vec<(bool, usize, f64)> {
ids.iter()
.map(|i| {
let (p, _) = outline(&node(s, *i));
(p.is_closed(), p.anchor_count(), (p.length() * 1e6).round() / 1e6)
})
.collect()
}
#[test]
fn shape_builder_erase_click_on_an_edge_opens_its_path() {
let mut s = session();
corner(&mut s);
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
assert_eq!(r["edges"].as_array().unwrap().len(), 4, "each outline cut in two where the other crosses it: {r}");
// A's side inside B (1 pt off it).
let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "erase": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r["lines"].as_u64(), r["edges"].as_u64()), (Some(0), Some(0), Some(1)));
let out = ids(&r);
// A opens where B's outline crosses it and keeps its look; B is untouched.
assert_eq!(shapes_of(&s, &out), vec![(false, 5, 300.0), (true, 4, 400.0)]);
assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
s.execute("edit.undo", &json!({})).unwrap();
let back: Vec<NodeId> = s.doc().unwrap().doc.children(s.doc().unwrap().doc.default_layer()).unwrap().iter().map(|n| n.id).collect();
assert_eq!(shapes_of(&s, &back), vec![(true, 4, 400.0); 2], "one undo step");
}
#[test]
fn shape_builder_erase_drag_along_edges_deletes_each() {
let mut s = session();
corner(&mut s);
// Down A's side inside B, round the corner and along B's side inside A.
let r = s.execute("shapeBuilder.merge", &json!({"points": [[100, 75], [100, 50], [75, 50]], "erase": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r["edges"].as_u64()), (Some(0), Some(2)));
assert_eq!(shapes_of(&s, &ids(&r)), vec![(false, 5, 300.0); 2]);
}
#[test]
fn shape_builder_erase_drag_into_regions_deletes_them_not_the_edges_it_crosses() {
let mut s = session();
corner(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 25], [75, 75]], "erase": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r["edges"].as_u64()), (Some(2), Some(0)));
// A is gone; what is left of B stays closed and blue.
let out = ids(&r);
assert_eq!(out.len(), 1);
assert!((area(&s, out[0]) - 7500.0).abs() < 1e-6);
assert!(outline(&node(&s, out[0])).0.is_closed());
assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
}
// ---------- Live Paint ----------
fn live(s: &mut Session) -> NodeId {
+1 -1
View File
@@ -20,7 +20,7 @@ pub use edit::{
};
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
pub use planar::{SHAPE_BUILDER_MAX_SEGMENTS, interior_point, live_paint, shape_builder};
pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, interior_point, live_paint, shape_builder};
/// Errors from fallible operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
+253 -21
View File
@@ -21,7 +21,7 @@ use std::collections::HashMap;
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, PathSeg, Point, Shape as _};
use linesweeper::topology::{Topology, WindingNumber};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_geom::{FillRule, PathData, SubPath};
use crate::boolean::{DEFAULT_PRECISION, Seg, Tidy, eps_for, is_sliver, segs_to_subpath, snap_horizontals, tidy_segments, to_seg};
use crate::pathfinder::{Region, Shape};
@@ -102,7 +102,12 @@ struct Outlines {
impl Outlines {
fn new(inputs: &[(BezPath, usize)], eps: f64) -> Self {
let segs = inputs.iter().flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), s, *i))).collect();
// A zero-length segment (a point) is the source of no edge, even one running through it.
let segs = inputs
.iter()
.flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), s, *i)))
.filter(|(r, ..)| r.width() > 0.0 || r.height() > 0.0)
.collect();
// The sweep moves points by a few `eps` at most.
Self { segs, tol: 16.0 * eps }
}
@@ -292,26 +297,172 @@ pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
/// than the filled areas' arrangement, so past this only the filled areas count.
pub const SHAPE_BUILDER_MAX_SEGMENTS: usize = 4096;
/// Shape Builder regions of `shapes` (back → front), and the pieces of their open paths.
/// The Shape Builder's arrangement: see [`shape_builder`].
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BuilderArrangement {
pub regions: Vec<Region>,
/// The pieces of the paths with no closed subpath, keyed by input index.
pub lines: Vec<Shape>,
/// The pieces of the other paths' outlines, keyed by input index (the front-most where inputs
/// share one); only when asked for.
pub edges: Vec<Shape>,
}
/// Shape Builder regions of `shapes` (back → front), the pieces of their open paths and, with
/// `edges`, the pieces of their outlines (what erasing an edge deletes).
///
/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions). Open
/// paths (the caller closes the ones whose fill should count) are cutting edges: a line across a
/// shape splits it, lines around an area enclose a region nothing fills (empty `sources`), and each
/// path with no closed subpath comes back cut wherever another path meets it, as pieces keyed by
/// its index in `shapes`. With no open path, more than [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or
/// a map that can't be built, only the filled areas count and there are no pieces.
pub fn shape_builder(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
/// its index in `shapes`. Outlines are cut the same way. With no open path, more than
/// [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or a map that can't be built, only the filled areas
/// count and there are no pieces.
pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement {
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
if has_open && segments <= SHAPE_BUILDER_MAX_SEGMENTS {
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
let (faces, edges) = live_paint(&keyed);
if !faces.is_empty() || !edges.is_empty() {
let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed));
return (faces, edges.into_iter().filter(|e| is_line(e.key)).collect());
let planar = ((has_open || edges) && segments <= SHAPE_BUILDER_MAX_SEGMENTS)
.then(|| {
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
live_paint(&keyed)
})
.filter(|(faces, pieces)| !faces.is_empty() || !pieces.is_empty());
let Some((faces, pieces)) = planar else {
return BuilderArrangement { regions: crate::regions(shapes), ..Default::default() };
};
let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed));
let (lines, outline): (Vec<Shape>, Vec<Shape>) = pieces.into_iter().partition(|e| is_line(e.key));
BuilderArrangement {
regions: if has_open { faces } else { crate::regions(shapes) },
lines: if has_open { lines } else { Vec::new() },
edges: if edges { outline } else { Vec::new() },
}
}
/// Does `sp`, closed, enclose an area (rather than retrace itself)? The Shape Builder closes the
/// open subpaths of filled paths that do.
pub fn encloses_area(sp: &SubPath) -> bool {
let mut closed = sp.clone();
closed.closed = true;
let mut bp = BezPath::new();
closed.to_bezpath_into(&mut bp);
let size = bp.bounding_box().size();
bp.area().abs() > 1e-9 * (size.width * size.width + size.height * size.height)
}
/// `path` without `piece`, a run along one of its subpaths between two points (a Shape Builder
/// edge): a closed subpath opens there, an open one splits in two (or loses an end), and a piece
/// that runs all the way round takes its subpath. With `fill_closes`, open subpaths that enclose an
/// area ([`encloses_area`]) run on round an invisible closing edge, as the Shape Builder sees a
/// filled path: a piece may run over it, and it never comes back as a visible segment. None when
/// the piece doesn't lie on `path` within `tol`.
pub fn cut_out(path: &PathData, piece: &SubPath, fill_closes: bool, tol: f64) -> Option<PathData> {
let n = piece.segment_count();
if n == 0 {
return None;
}
// A point well inside the piece: the middle of its longest segment.
let arclen = |k: usize| kurbo::ParamCurveArclen::arclen(&piece.segment(k), 1e-3);
let mid = (0..n).map(|k| (arclen(k), k)).max_by(|x, y| x.0.total_cmp(&y.0)).map(|(_, k)| piece.segment(k).eval(0.5))?;
let (a, b) = (piece.anchors.first()?.p, piece.anchors.last()?.p);
let mut bp = BezPath::new();
piece.to_bezpath_into(&mut bp);
if bp.perimeter(1e-6) <= tol {
// A speck (where paths touch): there's nothing to cut.
return None;
}
// A piece that ends where it starts runs all the way round.
let whole = piece.closed || a.distance(b) <= tol;
// Each subpath as the piece may run along it: round its closing edge when it has one.
let ring = |sp: &SubPath| SubPath { anchors: sp.anchors.clone(), closed: sp.closed || (fill_closes && encloses_area(sp)) };
let (si, geom, um) = path
.subpaths
.iter()
.enumerate()
.filter_map(|(i, sp)| {
let g = ring(sp);
let (u, d) = nearest_u(&g, mid)?;
(d <= tol).then_some((i, g, u, d))
})
.min_by(|x, y| x.3.total_cmp(&y.3))
.map(|(i, g, u, _)| (i, g, u))?;
let len = geom.segment_count() as f64;
let real = path.subpaths.get(si)?.segment_count() as f64;
let at = |p: Point| nearest_u(&geom, p).filter(|(_, d)| *d <= tol).map(|(u, _)| u);
// The parameter spans of `geom` that stay (unrolled past the end of a ring).
let keep: Vec<(f64, f64)> = if whole {
Vec::new()
} else if geom.closed {
let (ua, ub) = (at(a)?, at(b)?);
let fwd = |from: f64, to: f64| (to - from).rem_euclid(len);
// The piece runs from one end through `mid` to the other; what stays runs on round.
let (start, end) = if fwd(ua, um) <= fwd(ua, ub) { (ub, ua) } else { (ua, ub) };
let rest = fwd(start, end);
if rest <= 1e-9 || len - rest <= 1e-9 {
Vec::new()
} else {
// Where the ring closes with an invisible edge (`real..len`), what stays stops at it.
let parts: &[(f64, f64)] = if real >= len { &[(0.0, 2.0 * len)] } else { &[(0.0, real), (len, len + real)] };
parts.iter().map(|&(lo, hi)| (start.max(lo), (start + rest).min(hi))).filter(|(x, y)| y - x > 1e-9).collect()
}
} else {
let (ua, ub) = (at(a)?, at(b)?);
let (lo, hi) = (ua.min(ub), ua.max(ub));
if !(lo - 1e-9..=hi + 1e-9).contains(&um) {
return None;
}
[(0.0, lo), (hi, len)].into_iter().filter(|(x, y)| y - x > 1e-9).collect()
};
let mut subpaths: Vec<SubPath> = path.subpaths.iter().take(si).cloned().collect();
subpaths.extend(keep.into_iter().filter_map(|(u0, u1)| span(&geom, u0, u1)).map(|sp| snap_ends(sp, [a, b], tol)));
subpaths.extend(path.subpaths.iter().skip(si + 1).cloned());
Some(PathData::new(subpaths))
}
/// The point of `sp` nearest `p`, as a parameter `segment + t`, with its distance.
fn nearest_u(sp: &SubPath, p: Point) -> Option<(f64, f64)> {
use kurbo::ParamCurveNearest;
(0..sp.segment_count())
.map(|k| {
let n = sp.segment(k).nearest(p, 1e-9);
(k as f64 + n.t, n.distance_sq.sqrt())
})
.min_by(|x, y| x.1.total_cmp(&y.1))
}
/// `sp` with its end anchors within `tol` of one of `to` put exactly on it (where the planar map
/// put the junction a piece ends at), handles moving with them.
fn snap_ends(mut sp: SubPath, to: [Point; 2], tol: f64) -> SubPath {
let last = sp.anchors.len().saturating_sub(1);
for i in [0, last] {
if let Some(an) = sp.anchors.get_mut(i)
&& let Some(q) = to.iter().find(|q| q.distance(an.p) <= tol)
{
let d = *q - an.p;
an.p = *q;
an.h_in += d;
an.h_out += d;
}
}
(crate::regions(shapes), Vec::new())
sp
}
/// The run of `sp` from parameter `u0` to `u1` (`u0 < u1`, unrolled past the end of a closed one),
/// open.
fn span(sp: &SubPath, u0: f64, u1: f64) -> Option<SubPath> {
let n = sp.segment_count();
if n == 0 || !(u0.is_finite() && u1.is_finite()) {
return None;
}
let first = u0.floor().max(0.0) as usize;
let last = (u1.ceil().max(0.0) as usize).min(first + 2 * n);
let segs: Vec<Seg> = (first..last)
.filter_map(|k| {
let (t0, t1) = ((u0 - k as f64).clamp(0.0, 1.0), (u1 - k as f64).clamp(0.0, 1.0));
(t1 - t0 > 1e-12).then(|| Seg { c: sp.segment(k % n).subsegment(t0..t1), line: sp.segment_is_line(k % n) })
})
.collect();
segs_to_subpath(&segs, false)
}
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
@@ -618,7 +769,7 @@ pub fn interior_point(path: &PathData) -> Option<Point> {
mod tests {
use super::*;
use proptest::prelude::*;
use vectorcraft_geom::{Rect, SubPath, shapes};
use vectorcraft_geom::{Rect, shapes};
fn line(a: (f64, f64), b: (f64, f64), key: u64) -> Shape {
Shape::new(PathData::single(SubPath::polyline(&[Point::new(a.0, a.1), Point::new(b.0, b.1)], false)), FillRule::NonZero, key)
@@ -766,14 +917,14 @@ mod tests {
fn shape_builder_lines_cut_regions() {
// A line across a rectangle: two regions, the line in three pieces keyed by its index.
let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)];
let (regions, lines) = shape_builder(&s);
let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false);
assert_eq!(regions.len(), 2);
assert!(regions.iter().all(|r| r.sources == [0]));
assert_eq!(lines.len(), 3);
assert!(lines.iter().all(|l| l.key == 1 && !l.path.is_closed()));
// Lines alone enclose a region nothing fills.
let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 0), line((-10.0, 80.0), (110.0, 80.0), 0)];
let (regions, lines) = shape_builder(&s);
let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false);
assert_eq!(regions.len(), 1);
assert!(regions[0].sources.is_empty() && regions[0].top().is_none());
assert_eq!(lines.len(), 9);
@@ -782,10 +933,10 @@ mod tests {
#[test]
fn shape_builder_closed_only_or_degenerate_is_the_filled_arrangement() {
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)];
assert_eq!(shape_builder(&s), (crate::regions(&s), vec![]));
assert_eq!(shape_builder(&s, false), BuilderArrangement { regions: crate::regions(&s), ..Default::default() });
// A broken line can't be swept: what the filled areas give (nothing here, without a panic).
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), line((0.0, 0.0), (f64::NAN, 1.0), 1)];
assert_eq!(shape_builder(&s), (crate::regions(&s), vec![]));
assert_eq!(shape_builder(&s, false), BuilderArrangement { regions: crate::regions(&s), ..Default::default() });
// Zero-length and doubled-back lines don't break it either.
let s = [
rect(0.0, 0.0, 100.0, 100.0, 0),
@@ -793,7 +944,7 @@ mod tests {
line((0.0, 0.0), (100.0, 0.0), 2),
line((50.0, 0.0), (50.0, 100.0), 3),
];
let (regions, _) = shape_builder(&s);
let regions = shape_builder(&s, false).regions;
assert_eq!(regions.len(), 2);
assert!((regions.iter().map(area).sum::<f64>() - 10000.0).abs() < 1e-6);
}
@@ -815,14 +966,75 @@ mod tests {
s.push(line((-150.0, y), (150.0, y + 7.0), 1));
}
let started = std::time::Instant::now();
let (regions, lines) = shape_builder(&s);
let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false);
assert!(regions.len() >= 5, "the ring cut in five bands at least (the waves make pockets)");
assert!(lines.len() >= 12, "{}", lines.len());
assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed());
// Past the cap, only the filled areas count.
s.push(line((0.0, 0.0), (1.0, 1.0), 2));
s[0].path.subpaths[0].anchors.extend((0..16).map(|k| vectorcraft_geom::Anchor::corner(Point::new(100.0, f64::from(k)))));
assert!(shape_builder(&s).1.is_empty());
assert!(shape_builder(&s, false).lines.is_empty());
}
fn poly(pts: &[(f64, f64)], closed: bool) -> SubPath {
SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), closed)
}
fn ends(sp: &SubPath) -> (Point, Point) {
(sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p)
}
#[test]
fn shape_builder_edges_are_the_outlines_cut_where_paths_meet() {
// Two overlapping squares: each outline in two pieces, cut where the other crosses it.
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 50.0, 150.0, 150.0, 1)];
let with = shape_builder(&s, true);
assert_eq!(with.regions, shape_builder(&s, false).regions, "the regions don't change");
assert!(with.lines.is_empty() && shape_builder(&s, false).edges.is_empty());
assert_eq!(with.edges.len(), 4);
for key in [0, 1] {
assert_eq!(with.edges.iter().filter(|e| e.key == key && !e.path.is_closed()).count(), 2);
}
let inner = with.edges.iter().find(|e| e.key == 0 && e.path.bounds() == Some(Rect::new(50.0, 50.0, 100.0, 100.0))).expect("A inside B");
// Erasing it opens A there.
let cut = cut_out(&s[0].path, &inner.path.subpaths[0], false, 1e-3).expect("on A");
assert_eq!(cut.subpaths.len(), 1);
let sp = &cut.subpaths[0];
assert!(!sp.closed && sp.anchors.len() == 5, "{sp:?}");
let (a, b) = ends(sp);
assert!([a, b].contains(&Point::new(100.0, 50.0)) && [a, b].contains(&Point::new(50.0, 100.0)), "{a:?} {b:?}");
assert!((cut.length() - 300.0).abs() < 1e-6, "{}", cut.length());
// Not on B.
assert_eq!(cut_out(&s[1].path, &inner.path.subpaths[0], false, 1e-3), None);
// A lone square's edge is its whole outline: erasing it leaves nothing.
let lone = shape_builder(&s[..1], true);
assert_eq!(lone.edges.len(), 1);
assert!(cut_out(&s[0].path, &lone.edges[0].path.subpaths[0], false, 1e-3).unwrap().is_empty());
}
#[test]
fn cut_out_splits_open_paths_and_keeps_closing_edges_invisible() {
let open = PathData::single(poly(&[(0.0, 0.0), (100.0, 0.0)], false));
let cut = cut_out(&open, &poly(&[(60.0, 0.0), (30.0, 0.0)], false), false, 1e-3).unwrap();
let mut parts: Vec<(Point, Point)> = cut.subpaths.iter().map(ends).collect();
parts.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
assert_eq!(parts, vec![(Point::new(0.0, 0.0), Point::new(30.0, 0.0)), (Point::new(60.0, 0.0), Point::new(100.0, 0.0))]);
// An end piece only shortens it.
let cut = cut_out(&open, &poly(&[(70.0, 0.0), (100.0, 0.0)], false), false, 1e-3).unwrap();
assert_eq!(cut.subpaths.iter().map(ends).collect::<Vec<_>>(), vec![(Point::new(0.0, 0.0), Point::new(70.0, 0.0))]);
// A filled open path runs on round its invisible closing edge (100,100) → (0,0).
let corner = PathData::single(poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false));
let piece = poly(&[(100.0, 50.0), (100.0, 100.0), (50.0, 50.0)], false);
assert_eq!(cut_out(&corner, &piece, false, 1e-3), None, "without the fill it isn't on the path");
let cut = cut_out(&corner, &piece, true, 1e-3).unwrap();
assert_eq!(cut.subpaths.len(), 1);
assert_eq!(cut.subpaths[0].anchors.iter().map(|a| a.p).collect::<Vec<_>>(), [(0.0, 0.0), (100.0, 0.0), (100.0, 50.0)].map(Point::from));
// Cut elsewhere, the closing edge stays invisible: two open runs, not one through it.
let cut = cut_out(&corner, &poly(&[(20.0, 0.0), (60.0, 0.0)], false), true, 1e-3).unwrap();
assert_eq!(cut.subpaths.len(), 2);
assert!(cut.subpaths.iter().all(|sp| !sp.closed));
assert!((cut.length() - 160.0).abs() < 1e-6, "{}", cut.length());
}
fn arb_segment() -> impl Strategy<Value = Shape> {
@@ -872,6 +1084,26 @@ mod tests {
prop_assert!((filled - regions).abs() <= 1e-9 + DEFAULT_PRECISION * outlines, "{} vs {}", filled, regions);
}
/// Every edge and line piece cuts out of the input it is keyed by, which loses its length
/// and nothing else.
#[test]
fn pieces_cut_out_of_their_inputs(shapes in prop::collection::vec(arb_segment(), 1..6)) {
let arr = shape_builder(&shapes, true);
for e in arr.edges.iter().chain(&arr.lines) {
let (Some(s), Some(piece)) = (shapes.get(e.key as usize), e.path.subpaths.first()) else { continue };
let len = e.path.length();
let cut = cut_out(&s.path, piece, false, 0.05);
if len <= 0.05 {
// A speck where two paths touch.
prop_assert!(cut.is_none());
continue;
}
prop_assert!(cut.is_some(), "{:?} not on {:?}", piece, s.path);
let lost = s.path.length() - cut.map_or(0.0, |c| c.length());
prop_assert!((lost - len).abs() <= 0.05 + 1e-3 * len, "lost {} for a piece {} long", lost, len);
}
}
/// Lines anywhere (not on a grid): faces stay disjoint, and nothing panics.
#[test]
fn free_lines_make_disjoint_faces(pts in prop::collection::vec((-50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64), 2..9)) {
+181 -56
View File
@@ -162,12 +162,7 @@ fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec<po::Sha
continue;
}
for sp in s.path.subpaths.iter_mut().filter(|sp| !sp.closed) {
let mut closed = sp.clone();
closed.closed = true;
let mut bp = BezPath::new();
closed.to_bezpath_into(&mut bp);
let size = bp.bounding_box().size();
sp.closed = bp.area().abs() > 1e-9 * (size.width * size.width + size.height * size.height);
sp.closed = po::encloses_area(sp);
}
}
(shapes, leaves)
@@ -195,6 +190,9 @@ pub struct BuilderMap {
pub regions: Vec<Face>,
/// Pieces of the unfilled open paths, keyed by input index, with their bounds.
pub lines: Vec<(po::Shape, Rect)>,
/// Pieces of the other inputs' outlines (cut wherever paths meet), keyed by input index, with
/// their bounds: the edges erasing deletes. Only in a map made `with_edges`.
pub edges: Vec<(po::Shape, Rect)>,
}
/// What a Shape Builder gesture touches at a point.
@@ -202,18 +200,43 @@ pub struct BuilderMap {
pub enum Hit {
Region(usize),
Line(usize),
Edge(usize),
}
/// The piece of `pieces` nearest `p` within `tol`, with its distance.
fn piece_near(pieces: &[(po::Shape, Rect)], p: Point, tol: f64) -> Option<(f64, usize)> {
let mut best: Option<(f64, usize)> = None;
for (i, (l, b)) in pieces.iter().enumerate() {
if b.inflate(tol, tol).contains(p)
&& let Some((.., d)) = l.path.nearest(p)
&& d <= tol
&& best.is_none_or(|b| d < b.0)
{
best = Some((d, i));
}
}
best
}
impl BuilderMap {
/// The map of `roots` (sorted back → front), with the leaves the inputs came from.
pub fn new<'a>(doc: &'a Document, roots: &[NodeId]) -> (Self, Vec<&'a Node>) {
/// The map of `roots` (sorted back → front), with the leaves the inputs came from; the outline
/// edges only `with_edges` (they cost a planar map of their own when no open path is in).
pub fn new<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool) -> (Self, Vec<&'a Node>) {
let (shapes, leaves) = shape_builder_shapes(doc, roots);
if shapes.is_empty() {
return (Self::default(), leaves);
}
let (regions, lines) = po::shape_builder(&shapes);
let lines = lines.into_iter().filter_map(|l| Some((l.path.bounds()?, l))).map(|(b, l)| (l, b)).collect();
(Self { shapes, regions: faces_of(regions), lines }, leaves)
let arr = po::shape_builder(&shapes, with_edges);
let bounded = |pieces: Vec<po::Shape>| {
pieces
.into_iter()
.filter_map(|l| {
let b = l.path.bounds()?;
Some((l, b))
})
.collect()
};
(Self { shapes, regions: faces_of(arr.regions), lines: bounded(arr.lines), edges: bounded(arr.edges) }, leaves)
}
/// The region containing `p`.
@@ -221,53 +244,56 @@ impl BuilderMap {
self.regions.iter().position(|(_, bp, b)| b.contains(p) && bp.winding(p) != 0)
}
/// The line piece nearest `p`, within `tol`.
pub fn line_near(&self, p: Point, tol: f64) -> Option<usize> {
let mut best: Option<(f64, usize)> = None;
for (i, (l, b)) in self.lines.iter().enumerate() {
if b.inflate(tol, tol).contains(p)
&& let Some((.., d)) = l.path.nearest(p)
&& d <= tol
&& best.is_none_or(|b| d < b.0)
{
best = Some((d, i));
}
}
best.map(|b| b.1)
/// What a gesture at `p` touches: with `piece_tol` (erasing), the line piece or edge that
/// close, else the region under it.
pub fn hit(&self, p: Point, piece_tol: Option<f64>) -> Option<Hit> {
let piece = piece_tol.and_then(|t| match (piece_near(&self.lines, p, t), piece_near(&self.edges, p, t)) {
(Some(l), Some(e)) if e.0 < l.0 => Some(Hit::Edge(e.1)),
(Some(l), _) => Some(Hit::Line(l.1)),
(None, e) => e.map(|e| Hit::Edge(e.1)),
});
piece.or_else(|| self.region_at(p).map(Hit::Region))
}
/// What a gesture at `p` touches: with `line_tol` (erasing), a line piece that close, else the
/// region under it.
pub fn hit(&self, p: Point, line_tol: Option<f64>) -> Option<Hit> {
line_tol.and_then(|t| self.line_near(p, t)).map(Hit::Line).or_else(|| self.region_at(p).map(Hit::Region))
}
/// Does line piece `i` run between two of `regions` (an edge that merging or deleting them
/// removes)?
pub fn line_between(&self, i: usize, regions: &[usize]) -> bool {
let Some(sp) = self.lines.get(i).and_then(|(l, _)| l.path.subpaths.first()) else { return false };
/// The regions just either side of the middle of `piece` (None: outside every region).
fn sides(&self, piece: &po::Shape) -> [Option<usize>; 2] {
let Some(sp) = piece.path.subpaths.first() else { return [None; 2] };
let n = sp.segment_count();
if n == 0 {
return false;
return [None; 2];
}
let c = sp.segment(n / 2);
use kurbo::{ParamCurve as _, ParamCurveDeriv as _};
let (m, d) = (c.eval(0.5), c.deriv().eval(0.5).to_vec2());
let len = d.hypot();
if !(len.is_finite() && len > 0.0) {
return false;
return [None; 2];
}
// A little to each side: inside the faces next to it, which share it exactly.
let off = d.turn_90() * (0.05f64.min(0.05 * c.p0.distance(c.p3)).max(1e-6) / len);
[m + off, m - off].into_iter().all(|q| self.region_at(q).is_some_and(|r| regions.contains(&r)))
[m + off, m - off].map(|q| self.region_at(q))
}
/// Does line piece `i` run between two of `regions` (an edge that merging or deleting them
/// removes)?
pub fn line_between(&self, i: usize, regions: &[usize]) -> bool {
self.lines.get(i).is_some_and(|(l, _)| self.sides(l).iter().all(|r| r.is_some_and(|r| regions.contains(&r))))
}
/// The edges `touched` deletes: those that bound none of its regions (deleting a region takes
/// its edges with it, so an erase drag that crosses an edge into a region deletes the region).
pub fn erased_edges(&self, touched: &Touched) -> Vec<usize> {
let bounds_one = |(e, _): &(po::Shape, Rect)| self.sides(e).iter().any(|r| r.is_some_and(|r| touched.regions.contains(&r)));
touched.edges.iter().copied().filter(|&k| self.edges.get(k).is_some_and(|e| !bounds_one(e))).collect()
}
}
/// The regions and line pieces a Shape Builder gesture has touched, in the order touched.
/// The regions, line pieces and edges a Shape Builder gesture has touched, in the order touched.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Touched {
pub regions: Vec<usize>,
pub lines: Vec<usize>,
pub edges: Vec<usize>,
}
impl Touched {
@@ -275,6 +301,7 @@ impl Touched {
let (list, i) = match hit {
Some(Hit::Region(i)) => (&mut self.regions, i),
Some(Hit::Line(i)) => (&mut self.lines, i),
Some(Hit::Edge(i)) => (&mut self.edges, i),
None => return,
};
if !list.contains(&i) {
@@ -283,7 +310,7 @@ impl Touched {
}
pub fn is_empty(&self) -> bool {
self.regions.is_empty() && self.lines.is_empty()
self.regions.is_empty() && self.lines.is_empty() && self.edges.is_empty()
}
}
@@ -381,36 +408,42 @@ struct RegionCache {
map: BuilderMap,
/// Live Paint faces (every path an edge, filled or not) rather than Shape Builder regions.
live: bool,
/// The map has the Shape Builder's outline edges.
edges: bool,
}
impl RegionCache {
fn live_paint() -> Self {
Self { live: true, ..Default::default() }
}
fn get(&mut self, cx: &ToolContext) -> &BuilderMap {
/// The map, with the outline edges when `edges` (built the first time they're wanted).
fn get(&mut self, cx: &ToolContext, edges: bool) -> &BuilderMap {
let key = (cx.doc as *const Document as usize, cx.selection.objects.clone());
if self.key.as_ref() != Some(&key) {
if self.key.as_ref() != Some(&key) || (edges && !self.edges) {
let roots = sorted_roots(cx.doc, &cx.selection.objects);
self.map = if self.live {
let (shapes, _) = shapes_for(cx.doc, &roots);
let regions = if shapes.is_empty() { vec![] } else { faces_of(po::live_paint(&shapes).0) };
BuilderMap { regions, ..Default::default() }
} else {
BuilderMap::new(cx.doc, &roots).0
BuilderMap::new(cx.doc, &roots, edges).0
};
self.key = Some(key);
self.edges = edges;
}
&self.map
}
fn at(&mut self, cx: &ToolContext, p: Point) -> Option<usize> {
self.get(cx).region_at(p)
self.get(cx, false).region_at(p)
}
}
// ---------- Shape Builder ----------
/// Shape Builder tool: drag across regions to merge them, Alt-drag to delete them (and the pieces
/// of open paths it passes over).
/// Shape Builder tool: drag across regions to merge them. Holding Alt (Option) switches to erase
/// mode while it is held: the pointer shows a minus, what a click or drag would delete highlights in
/// red, and the gesture deletes the regions it touches, the pieces of open paths and the edges
/// (outline pieces between the points where paths meet) it passes over.
#[derive(Default)]
pub struct ShapeBuilderTool {
cache: RegionCache,
@@ -418,10 +451,11 @@ pub struct ShapeBuilderTool {
points: Vec<Point>,
touched: Touched,
dragging: bool,
/// Erase mode: Alt held while hovering, or when the drag started.
alt: bool,
}
/// How close (screen px) the pointer must come to a line piece to delete it.
/// How close (screen px) the pointer must come to a line piece or an edge to delete it.
const LINE_TOL_PX: f64 = 4.0;
impl ShapeBuilderTool {
@@ -435,9 +469,9 @@ impl ShapeBuilderTool {
None => vec![to],
};
let tol = Self::line_tol(cx, self.alt);
let map = self.cache.get(cx, self.alt);
for p in pts {
let hit = self.cache.get(cx).hit(p, tol);
self.touched.add(hit);
self.touched.add(map.hit(p, tol));
}
}
}
@@ -451,7 +485,8 @@ impl Tool for ShapeBuilderTool {
let p = ev.pos;
match ev.kind {
PointerKind::Move => {
self.hover = self.cache.get(cx).hit(p, Self::line_tol(cx, ev.mods.alt));
self.alt = ev.mods.alt;
self.hover = self.cache.get(cx, self.alt).hit(p, Self::line_tol(cx, self.alt));
vec![]
}
PointerKind::Down => {
@@ -487,7 +522,8 @@ impl Tool for ShapeBuilderTool {
self.cache.key = None;
let roots = sorted_roots(cx.doc, &cx.selection.objects);
let pts: Vec<Value> = points.iter().map(|q| json!([q.x, q.y])).collect();
let erase = self.alt || ev.mods.alt;
// The mode the drag started in, which its highlight showed.
let erase = self.alt;
let mut params = json!({ "ids": json_ids(&roots), "points": pts, "erase": erase });
if let Some(t) = Self::line_tol(cx, erase) {
params["tolerance"] = json!(t);
@@ -501,9 +537,11 @@ impl Tool for ShapeBuilderTool {
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
let map = &self.cache.map;
let color = if self.dragging && self.alt { HIGHLIGHT_RED } else { HATCH };
let color = if self.alt { HIGHLIGHT_RED } else { HATCH };
let show: Vec<Hit> = if self.dragging {
self.touched.regions.iter().map(|&i| Hit::Region(i)).chain(self.touched.lines.iter().map(|&i| Hit::Line(i))).collect()
let t = &self.touched;
let regions = t.regions.iter().map(|&i| Hit::Region(i));
regions.chain(t.lines.iter().map(|&i| Hit::Line(i))).chain(map.erased_edges(t).into_iter().map(Hit::Edge)).collect()
} else {
self.hover.into_iter().collect()
};
@@ -515,8 +553,9 @@ impl Tool for ShapeBuilderTool {
out.push(Overlay::Path { path: bp.clone(), color, width: 1.0, dashed: false });
}
}
Hit::Line(i) => {
if let Some((l, _)) = map.lines.get(i) {
Hit::Line(i) | Hit::Edge(i) => {
let pieces = if matches!(hit, Hit::Line(_)) { &map.lines } else { &map.edges };
if let Some((l, _)) = pieces.get(i) {
out.push(Overlay::Path { path: l.path.to_bezpath(), color: HIGHLIGHT_RED, width: 3.0, dashed: false });
}
}
@@ -533,8 +572,10 @@ impl Tool for ShapeBuilderTool {
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor {
// A drag keeps the mode it started in; hovering follows the key.
let erase = if self.dragging { self.alt } else { m.alt };
if erase { Cursor::ShapeBuilderErase } else { Cursor::ShapeBuilder }
}
fn busy(&self) -> bool {
@@ -803,6 +844,90 @@ mod tests {
assert!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)).is_empty());
}
/// Squares A (0..100) and B (50..150 both ways), overlapping at a corner, selected.
fn corner_rects() -> (Document, Selection) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let mut s = Selection::default();
for (k, r) in [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(50.0, 50.0, 150.0, 150.0)].into_iter().enumerate() {
let id = d.alloc_id();
d.insert(Some(l), k, Node::path(id, shapes::rectangle(r), Appearance::default_art())).unwrap();
s.add(id);
}
(d, s)
}
fn colors(ov: &[Overlay]) -> Vec<[u8; 3]> {
ov.iter().map(|o| if let Overlay::Path { color, .. } = o { *color } else { panic!("{o:?}") }).collect()
}
#[test]
fn shape_builder_alt_switches_to_erase_mode_while_held() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
let at = Point::new(75.0, 75.0);
// Merge mode: a grey mesh and the plus pointer.
t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y));
assert_eq!(colors(&t.overlays(&cx)), [HATCH; 2]);
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilder);
// Alt held: what would go highlights red, and the pointer shows a minus.
t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y).with_mods(alt));
assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 2]);
assert_eq!(t.cursor(&cx, at, alt), Cursor::ShapeBuilderErase);
// Released: merge mode again.
t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y));
assert_eq!(colors(&t.overlays(&cx)), [HATCH; 2]);
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilder);
// A drag keeps the mode it started in.
t.pointer(&cx, &ev(PointerKind::Down, at.x, at.y).with_mods(alt));
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilderErase);
let acts = t.pointer(&cx, &ev(PointerKind::Up, at.x, at.y));
let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!(v["erase"], true);
}
#[test]
fn shape_builder_alt_on_an_edge_highlights_and_deletes_it() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
// A's side inside B, between the points where B's outline crosses it.
t.pointer(&cx, &ev(PointerKind::Move, 101.0, 75.0).with_mods(alt));
assert!(matches!(t.hover, Some(Hit::Edge(_))), "{:?}", t.hover);
let ov = t.overlays(&cx);
let [Overlay::Path { path, color: HIGHLIGHT_RED, width: 3.0, .. }] = ov.as_slice() else { panic!("{ov:?}") };
assert_eq!(path.bounding_box(), Rect::new(50.0, 50.0, 100.0, 100.0));
// Without Alt it's the region under the pointer.
t.pointer(&cx, &ev(PointerKind::Move, 101.0, 75.0));
assert!(matches!(t.hover, Some(Hit::Region(_))));
t.pointer(&cx, &ev(PointerKind::Down, 101.0, 75.0).with_mods(alt));
assert_eq!(t.touched.edges.len(), 1);
let acts = t.pointer(&cx, &ev(PointerKind::Up, 101.0, 75.0).with_mods(alt));
let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!((id.as_str(), &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(4.0)));
}
#[test]
fn shape_builder_erase_drag_into_regions_shows_the_regions_not_the_edges_it_crosses() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0).with_mods(alt));
t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0).with_mods(alt));
assert_eq!(t.touched.regions.len(), 2);
assert!(!t.touched.edges.is_empty(), "the drag crossed B's outline");
assert!(t.cache.map.erased_edges(&t.touched).is_empty(), "which goes with the regions");
// Two regions (mesh + outline each) and the drag's trail, all red.
assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 5]);
}
#[test]
fn hatch_stays_inside_region() {
let bp = shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)).to_bezpath();
+7 -1
View File
@@ -412,6 +412,10 @@ pub enum Cursor {
BlendAnchor,
/// Over a bracket of selected type on a path (a drag moves it): the arrow with a bracket.
PathBracket,
/// The Shape Builder: a crosshair with a plus (merge mode)...
ShapeBuilder,
/// ...or, with Alt held, a minus (erase mode).
ShapeBuilderErase,
}
impl Cursor {
@@ -429,7 +433,9 @@ impl Cursor {
| Cursor::Slice
| Cursor::Blend
| Cursor::BlendObject
| Cursor::BlendAnchor => Cursor::Crosshair,
| Cursor::BlendAnchor
| Cursor::ShapeBuilder
| Cursor::ShapeBuilderErase => Cursor::Crosshair,
c => c,
}
}
+11 -3
View File
@@ -423,7 +423,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::SliceSelect => C::Default,
Cursor::Width | Cursor::WidthAdd => C::Crosshair,
Cursor::WidthPoint => C::Move,
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => C::Crosshair,
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair,
}
}
@@ -438,6 +438,14 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
app.hover_doc = hover.map(|p| xf.to_doc(p));
let view = app.view_info();
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
// A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows
// without moving the mouse (Alt switches the Shape Builder to erase mode).
let mods_changed = ui.data_mut(|d| {
let id = egui::Id::new("canvas-mods");
let prev = d.get_temp::<egui::Modifiers>(id);
d.insert_temp(id, m);
prev.is_some_and(|prev| prev != m)
});
// Zoom around the pointer, or scroll ([`wheel`]).
if resp.hovered() {
@@ -576,7 +584,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
}
}
} else if let Some(p) = hover
&& pointer.is_moving()
&& (pointer.is_moving() || mods_changed)
{
let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
@@ -595,7 +603,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
}
}
}
if drag.is_some() || pointer.is_moving() {
if drag.is_some() || pointer.is_moving() || mods_changed {
ui.ctx().request_repaint();
}
}
+13
View File
@@ -201,6 +201,17 @@ fn blend(p: &Painter, o: Pos2, badge: Cursor) {
poly(p, vec![b, b + vec2(7.0, 0.0), b + vec2(7.0, 7.0), b + vec2(0.0, 7.0)], fill, INK);
}
/// The Shape Builder: a crosshair with a plus badge below right of the hotspot (merge mode), or a
/// minus (erase mode).
fn shape_builder(p: &Painter, o: Pos2, erase: bool) {
crosshair(p, o);
let c = o + vec2(12.0, 12.0);
line(p, c - vec2(3.5, 0.0), c + vec2(3.5, 0.0));
if !erase {
line(p, c - vec2(0.0, 3.5), c + vec2(0.0, 3.5));
}
}
/// Paint cursor `c` at `p` on the given (foreground) painter. Returns false for cursors that should
/// stay system cursors (hand, zoom, busy states).
pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
@@ -237,6 +248,8 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
Cursor::WidthPoint => width(painter, p, "point"),
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(painter, p, c),
Cursor::PathBracket => path_bracket(painter, p),
Cursor::ShapeBuilder => shape_builder(painter, p, false),
Cursor::ShapeBuilderErase => shape_builder(painter, p, true),
_ => return false,
}
let _ = pos2;
+27 -3
View File
@@ -103,6 +103,31 @@ fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str, enabled: boo
changed
}
/// The size of the colour chip before each name in the Color list.
const CHIP: egui::Vec2 = egui::vec2(16.0, 10.0);
/// The Color list: each preset's name after a chip of its colour, then Custom (no chip, its
/// name aligned with the others). Returns the chosen row; Custom is `LAYER_COLORS.len()`.
fn color_dropdown(ui: &mut egui::Ui, shown: &str) -> Option<usize> {
let border = crate::theme::Tokens::get(ui.ctx()).input_border;
let rows = LAYER_COLORS.iter().map(|(n, c)| (*n, Some(*c))).chain([("Custom", None)]);
widgets::combo(ui, "layer-color", tl!(shown), 140.0, false, |ui| {
let mut chosen = None;
for (i, (name, rgb)) in rows.enumerate() {
let chip = ui.id().with(("layer-color-chip", i));
let r = egui::Button::selectable(name == shown, (egui::Atom::custom(chip, CHIP), tl!(name))).atom_ui(ui);
if let (Some(rect), Some([cr, cg, cb])) = (r.rect(chip), rgb) {
ui.painter().rect_filled(rect, 1.0, egui::Color32::from_rgb(cr, cg, cb));
ui.painter().rect_stroke(rect, 1.0, egui::Stroke::new(1.0, border), egui::StrokeKind::Inside);
}
if r.response.clicked() {
chosen = Some(i);
}
}
chosen
})
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let layer = d.bool("layer");
let many = d.fields.get("ids").and_then(Value::as_array).is_some_and(|a| a.len() > 1);
@@ -116,9 +141,8 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
label(ui, tl!("Color:"));
ui.horizontal(|ui| {
let cur = d.str("color");
let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
let shown = if LAYER_COLORS.iter().any(|(n, _)| n.eq_ignore_ascii_case(&cur)) { cur.clone() } else { "Custom".into() };
if let Some((n, _)) = widgets::dropdown(ui, "layer-color", &shown, &names, 140.0).and_then(|i| LAYER_COLORS.get(i)) {
let shown = LAYER_COLORS.iter().map(|(n, _)| *n).find(|n| n.eq_ignore_ascii_case(&cur)).unwrap_or("Custom");
if let Some((n, _)) = color_dropdown(ui, shown).and_then(|i| LAYER_COLORS.get(i)) {
d.fields.insert("color".into(), json!(n));
}
let [r, g, b] = LAYER_COLORS
+4
View File
@@ -604,3 +604,7 @@
Version Verze
% of all commits % všech commitů
Lines +/− Řádky +/−
Drag to move Přetažením přesunete
Drag to float as a toolbar Přetažením vytvoříte plovoucí panel nástrojů
Put back in the toolbar Vrátit do panelu nástrojů
Float Tool Group Plovoucí skupina nástrojů
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3029,3 +3029,7 @@
axis Horizontal Horizontal
axis Vertical Vertical
axis Both Ambos
Drag to move Arrastrar para mover
Drag to float as a toolbar Arrastrar para flotar como barra de herramientas
Put back in the toolbar Devolver a la barra de herramientas
Float Tool Group Hacer flotar el grupo de herramientas
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3018,3 +3018,7 @@
axis Horizontal 水平方向
axis Vertical 垂直方向
axis Both 両方
Drag to move 移動するにはドラッグ
Drag to float as a toolbar ドラッグしてツールバーとして浮かせる
Put back in the toolbar ツールバーに戻す
Float Tool Group ツールグループをフローティング
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -2621,3 +2621,7 @@
Version Versão
% of all commits % de todos os commits
Lines +/− Linhas +/−
Drag to move Arraste para mover
Drag to float as a toolbar Arraste para flutuar como barra de ferramentas
Put back in the toolbar Devolver à barra de ferramentas
Float Tool Group Flutuar Grupo de Ferramentas
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -3275,3 +3275,7 @@
axis Horizontal 水平
axis Vertical 垂直
axis Both 两者
Drag to move 拖动以移动
Drag to float as a toolbar 拖动使其浮动为工具栏
Put back in the toolbar 放回工具栏
Float Tool Group 浮动工具组
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3049,3 +3049,7 @@
axis Horizontal 水平
axis Vertical 垂直
axis Both 兩者
Drag to move 拖曳以移動
Drag to float as a toolbar 拖曳使其浮動為工具列
Put back in the toolbar 放回工具列
Float Tool Group 浮動工具組
Can't render this file because it contains an unexpected character in line 6 and column 47.
+19
View File
@@ -199,6 +199,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.toolbar", "Tools", "", "{}"),
("window.toolbarColumns", "Toolbar: Single/Double Column", "", "{}"),
("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"),
(
"window.floatTools",
"Float Tool Group",
"",
"{tool: id, floating?: bool} float the toolbar group holding `tool` (in the current Basic or Advanced layout) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state",
),
("window.taskBar", "Contextual Task Bar", "", "{}"),
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"),
@@ -980,6 +986,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"window.toolbar" => flag(&mut app.ui.toolbar),
"window.toolbarColumns" => flag(&mut app.ui.toolbar_double),
"window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced),
"window.floatTools" => {
let floating = match p.get("floating") {
None | Some(Value::Null) => None,
Some(Value::Bool(b)) => Some(*b),
Some(_) => return Some(Err("floating must be true or false".into())),
};
let Some(tool) = s("tool") else { return Some(Err("tool (a tool id) is required".into())) };
crate::toolbar::float_group(app, &tool, floating).map(Value::Bool)
}
"window.taskBar" => flag(&mut app.ui.task_bar),
"window.dock" => {
let on = !(app.ui.dock && app.ui.toolbar);
@@ -1302,6 +1317,10 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
"window.control" => app.ui.control_bar,
"window.toolbar" => app.ui.toolbar,
"window.toolbarAdvanced" => app.ui.toolbar_advanced,
"window.floatTools" => {
let tool = p.get("tool").and_then(Value::as_str).unwrap_or("");
app.ui.floating_flyouts.iter().any(|f| f.tools.iter().any(|id| id == tool))
}
"window.taskBar" => app.ui.task_bar,
"window.panel" => {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
+174 -32
View File
@@ -15,7 +15,8 @@
//! shows every layer when they are hidden (`layer.showAll`); Alt-click a lock does the same with
//! `layer.lockOthers` and `layer.unlockAll`.
//! - Dragging rows moves them above, below or into a layer or group (`layer.move`, a drop line or
//! a box shows where); Alt copies them; dropped on the trash they are deleted.
//! a box shows where); Alt copies them. A dimmed copy of the row follows the pointer. Dropped on
//! the trash they are deleted, on New Layer duplicated.
//! - The triangle opens or closes a row; Alt-click does the same to everything inside it.
//! - Double-clicking a name renames it; double-clicking elsewhere on a row opens its options.
//! - A target circle targets its layer, group or object (`layer.target`); dragging it onto another
@@ -109,6 +110,57 @@ fn rename_id() -> egui::Id {
egui::Id::new("layers-rename")
}
/// Ghost rows are drawn at this opacity.
const GHOST_OPACITY: f32 = 0.5;
/// The colour of the layer holding `id`.
fn layer_colour(doc: &Document, id: NodeId) -> Color32 {
let [r, g, b] = doc.layer_color(id);
Color32::from_rgb(r, g, b)
}
/// The layer colour bar of row `n` (rect `r`) at `x`: wider on layers.
fn colour_bar(ui: &Ui, n: &Node, r: egui::Rect, x: f32, colour: Color32) {
let w = if n.is_layer() { 4.0 } else { 2.0 };
ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(w, r.height())), 0.0, colour);
}
/// The thumbnail of row `n` (rect `r`) at `x`; returns where it went.
fn row_thumb(ui: &Ui, view: &View, n: &Node, r: egui::Rect, x: f32) -> egui::Rect {
let s = (view.h - 4.0).min(40.0);
let th = egui::Rect::from_min_size(egui::pos2(x, r.center().y - s / 2.0), vec2(s, s));
ui.painter().rect_filled(th, 0.0, Color32::WHITE);
ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, view.t.border), StrokeKind::Outside);
if !real_thumb(ui, view.doc, n, th) {
thumb(ui, n, th);
}
th
}
/// The dragged row `src`, dimmed, following the pointer at `pos` above everything else: its
/// colour bar, thumbnail and name.
fn drag_ghost(ui: &Ui, view: &View, src: NodeId, pos: egui::Pos2) {
let (doc, t, h) = (view.doc, &view.t, view.h);
let Some(n) = doc.node(src) else { return };
let (grab, width, depth) = ui.data(|d| d.get_temp::<(egui::Vec2, f32, usize)>(key("grab"))).unwrap_or((vec2(h, h / 2.0), 240.0, 0));
// A plain Ui on the tooltip layer, not an Area: a new Area is invisible for a frame and fades in.
let r = egui::Rect::from_min_size(pos - grab, vec2(width, h));
let id = key("ghost");
let mut ui = Ui::new(ui.ctx().clone(), id, egui::UiBuilder::new().layer_id(egui::LayerId::new(egui::Order::Tooltip, id)).max_rect(r));
ui.set_opacity(GHOST_OPACITY);
let p = ui.painter();
p.rect_filled(r, 0.0, t.row_selected);
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
let mut x = r.left() + 2.0 * COLUMN + 2.0;
colour_bar(&ui, n, r, x, layer_colour(doc, src));
x += 22.0 + depth as f32 * INDENT;
if view.opts.thumb(n) {
x = row_thumb(&ui, view, n, r, x).right() + 4.0;
}
let name = painted_name(n, &n.display_name(), crate::i18n::current());
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), t.text);
}
/// Where a dragged row goes relative to the row it is dropped on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Place {
@@ -268,6 +320,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if released {
ui.data_mut(|d| d.remove::<u64>(egui::Id::new("layers-drag")));
}
// Dragged rows show as a dimmed row under the pointer.
if !released
&& let Some(drag) = egui::DragAndDrop::payload::<LayersDrag>(ui.ctx())
&& let LayersDrag::Rows(ids) = &*drag
&& let Some(src) = ids.first()
&& let Some(pos) = ui.input(|i| i.pointer.latest_pos())
{
drag_ghost(ui, &view, *src, pos);
}
// Keyboard: Delete removes the highlighted rows while the panel has the keyboard (a row was
// clicked last, not the canvas).
let pressed_outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !list_rect.contains(p)));
@@ -304,15 +365,13 @@ fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc: &Document
let m = ui.input(|i| i.modifiers);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
let trash = widgets::icon_button(&mut child, "trash-2", tl!("Delete Selection"), false, 24.0);
// A target circle dropped on the trash clears that appearance; dragged rows are deleted.
if let Some(d) = trash.dnd_release_payload::<PanelDrag>()
// A target circle dropped on the trash clears that appearance (dragged rows: below). Peek at
// the payload's type before taking it: egui's take drops a payload of another type.
if trash.dnd_hover_payload::<PanelDrag>().is_some()
&& let Some(d) = trash.dnd_release_payload::<PanelDrag>()
&& let PanelDrag::Appearance(id) = *d
{
actions.push(("appearance.clear".into(), json!({"ids": [id.0]})));
} else if let Some(d) = trash.dnd_release_payload::<LayersDrag>()
&& let LayersDrag::Rows(ids) = &*d
{
actions.push(("layer.delete".into(), json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})));
} else if trash.clicked() {
if view.rows.is_empty() && app.session.active().is_some_and(|d| !d.selection.is_empty()) {
actions.push(("edit.clear".into(), json!({})));
@@ -321,13 +380,28 @@ fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc: &Document
}
}
// Create New Layer: Alt asks for its options first, Ctrl/Cmd puts it on top of every layer.
if widgets::icon_button(&mut child, "file-plus", tl!("Create New Layer"), false, 24.0).clicked() {
let new_layer = widgets::icon_button(&mut child, "file-plus", tl!("Create New Layer"), false, 24.0);
if new_layer.clicked() {
if m.alt {
actions.push(("ui.newLayer".into(), json!({})));
} else {
actions.push(("layer.new".into(), json!({"top": m.command})));
}
}
// Rows dropped on the trash are deleted, on New Layer duplicated, as in Illustrator. Both are
// outlined while rows (or, on the trash, a target circle) hang over them.
for (button, cmd) in [(&trash, "layer.delete"), (&new_layer, "layer.duplicate")] {
if let Some(d) = button.dnd_release_payload::<LayersDrag>()
&& let LayersDrag::Rows(ids) = &*d
{
actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})));
}
let over = button.dnd_hover_payload::<LayersDrag>().is_some_and(|d| matches!(*d, LayersDrag::Rows(_)))
|| (cmd == "layer.delete" && button.dnd_hover_payload::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_))));
if over {
child.painter().rect_stroke(button.rect, 3.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
}
}
if widgets::icon_button(&mut child, "plus", tl!("Create New Sublayer"), false, 24.0).clicked() {
actions.push(if m.alt { ("ui.newLayer".into(), json!({"sublayer": true})) } else { ("layer.newSublayer".into(), json!({})) });
}
@@ -396,10 +470,7 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand
n.walk(&mut |c| any |= c.id != n.id && view.sel.contains(&c.id));
any
});
let color = {
let c = doc.layer_color(n.id);
Color32::from_rgb(c[0], c[1], c[2])
};
let color = layer_colour(doc, n.id);
// Highlighted rows (else the current layer's).
let highlighted = if view.rows.is_empty() { n.is_layer() && Some(n.id) == view.current } else { view.rows.contains(&n.id) };
if highlighted {
@@ -416,7 +487,7 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand
eye_and_lock(ui, view, n, r, out);
// Layer colour bar (every row shows the colour of its layer).
let mut x = r.left() + 2.0 * COLUMN + 2.0;
ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(if n.is_layer() { 4.0 } else { 2.0 }, h)), 0.0, color);
colour_bar(ui, n, r, x, color);
x += 6.0 + depth as f32 * INDENT;
// Disclosure triangle; Alt opens or closes everything inside too.
let searching = !view.query.is_empty();
@@ -440,14 +511,8 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand
x += 16.0;
// Thumbnail.
let thumb_rect = view.opts.thumb(n).then(|| {
let s = (h - 4.0).min(40.0);
let th = egui::Rect::from_min_size(egui::pos2(x, r.center().y - s / 2.0), vec2(s, s));
ui.painter().rect_filled(th, 0.0, Color32::WHITE);
ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
if !real_thumb(ui, doc, n, th) {
thumb(ui, n, th);
}
x += s + 4.0;
let th = row_thumb(ui, view, n, r, x);
x = th.right() + 4.0;
th
});
// Name.
@@ -533,6 +598,8 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand
if resp.drag_started() && view.column.is_none() {
let ids = if view.rows.contains(&n.id) { view.rows.clone() } else { vec![n.id] };
egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Rows(ids));
let grab = resp.interact_pointer_pos().map_or(vec2(h, h / 2.0), |p| p - r.min);
ui.data_mut(|d| d.insert_temp(key("grab"), (grab, r.width(), depth)));
}
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
@@ -1116,23 +1183,34 @@ mod tests {
// Dropped on the trash, the layer's appearance is cleared (and nothing is deleted).
let st = app.session.active().unwrap();
let count = st.doc.node_count();
let trash = {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui));
out.textures_delta.clear();
// The bottom bar's rightmost button, under the right end of its top divider.
let divider = Tokens::get(&ctx).divider;
let corner = out.shapes.iter().rev().find_map(|c| match &c.shape {
egui::Shape::LineSegment { points, stroke } if stroke.color == divider => Some(points[1]),
_ => None,
});
corner.unwrap() + vec2(-12.0, 15.0)
};
let trash = trash_pos(&mut app, &ctx);
drag(&mut app, &ctx, c[0], trash, false);
let st = app.session.active().unwrap();
assert_eq!(st.doc.node(layer).unwrap().opacity, 1.0);
assert_eq!(st.doc.node_count(), count);
}
/// The trash button: the bottom bar's rightmost button.
fn trash_pos(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 {
bar_buttons(app, ctx)[0]
}
#[test]
fn dragging_rows_onto_the_trash_deletes_them() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
let ctx = egui::Context::default();
// Rows top first: Layer 2, Layer 1. Grab Layer 2 by its name, left of its target circle.
let (c, _) = frame(&mut app, &ctx, vec![], false);
let top = app.session.active().unwrap().doc.layers[1].id;
let trash = trash_pos(&mut app, &ctx);
drag(&mut app, &ctx, c[0] - vec2(100.0, 0.0), trash, false);
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.layers.len(), 1);
assert!(doc.node(top).is_none());
}
#[test]
fn renaming_a_layer_commits_on_enter_and_escape_cancels() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -1396,6 +1474,70 @@ mod tests {
let _ = (layer, c);
}
/// The centres of the bottom bar's buttons, right to left from under the right end of its top
/// divider: Delete, New Layer, New Sublayer, Make/Release Clipping Mask, Locate Object.
fn bar_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<egui::Pos2> {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
out.textures_delta.clear();
let divider = Tokens::get(ctx).divider;
let corner = out
.shapes
.iter()
.rev()
.find_map(|c| match &c.shape {
egui::Shape::LineSegment { points, stroke } if stroke.color == divider => Some(points[1]),
_ => None,
})
.unwrap();
let step = 24.0 + ctx.global_style().spacing.item_spacing.x;
(0..5).map(|k| corner + vec2(-12.0 - k as f32 * step, 15.0)).collect()
}
/// How many texts reading `s` the frame after `events` paints.
fn texts(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, s: &str) -> usize {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == s)).count()
}
/// Drag the row at `a` to `b` and drop it there. Returns how many times its `name` was painted
/// while it hung over `b`.
fn drag_row(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, name: &str) -> usize {
frame(app, ctx, vec![egui::Event::PointerMoved(a)], false);
frame(app, ctx, vec![button(a, true)], false);
frame(app, ctx, vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))], false);
let shown = texts(app, ctx, vec![egui::Event::PointerMoved(b)], name);
frame(app, ctx, vec![button(b, false)], false);
frame(app, ctx, vec![], false);
shown
}
#[test]
fn dropping_rows_on_new_layer_duplicates_them() {
let (mut app, first, _) = two_rects();
let second = app.session.execute("layer.new", &json!({"name": "Top"})).unwrap()["id"].as_u64().map(NodeId).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let layers = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.id).collect::<Vec<_>>();
// Rows top down: Top, Layer 1 (expanded: its two rectangles).
let (c, _) = frame(&mut app, &ctx, vec![], false);
let buttons = bar_buttons(&mut app, &ctx);
// Layer 1 dropped on New Layer: a copy of it right above it.
let shown = drag_row(&mut app, &ctx, egui::pos2(150.0, c[1].y), buttons[1], "Layer 1");
assert_eq!(shown, 2, "the row and its ghost under the pointer");
let after = layers(&app);
assert_eq!(after.len(), 3);
assert_eq!((after[0], after[2]), (first, second));
assert_eq!(app.session.active().unwrap().doc.node(after[1]).unwrap().children().map(Vec::len), Some(2));
assert_eq!(texts(&mut app, &ctx, vec![], "Layer 1"), 1, "the ghost is gone after the drop");
// One undo step takes the copy away.
app.session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(layers(&app), vec![first, second]);
// The other bottom-bar buttons are no drop targets.
drag_row(&mut app, &ctx, egui::pos2(150.0, c[0].y), buttons[2], "Top");
assert_eq!(layers(&app), vec![first, second]);
}
#[test]
fn the_selected_art_square_drags_the_selection_to_another_layer() {
let (mut app, [_, sub, g, _, a, c]) = nested();
+19
View File
@@ -255,6 +255,9 @@ pub struct UiState {
/// Last tool shown in each toolbar slot (keyed by the slot's first tool id).
#[serde(default)]
pub slot_tool: std::collections::BTreeMap<String, String>,
/// Tool flyouts torn off the toolbar into floating panels.
#[serde(default)]
pub floating_flyouts: Vec<FloatingFlyout>,
pub status_bar: bool,
pub dock: bool,
pub view: ViewFlags,
@@ -368,6 +371,15 @@ pub struct WindowGeometry {
pub maximized: bool,
}
/// A tool group's flyout torn off the toolbar: it floats as its own panel until its × puts it back.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FloatingFlyout {
/// The group's tools in flyout order; the first one names the toolbar slot.
pub tools: Vec<String>,
/// Top-left corner in screen points.
pub pos: [f32; 2],
}
impl UiState {
/// Clear transient state after loading saved preferences.
pub fn sanitized(mut self) -> Self {
@@ -380,6 +392,12 @@ impl UiState {
if self.group_tool.len() != vectorcraft_tools::TOOL_GROUPS.len() {
self.group_tool = UiState::default().group_tool;
}
// One strip per group, of known tools (a hand-edited preferences file).
let mut seen = std::collections::BTreeSet::new();
self.floating_flyouts.retain_mut(|f| {
f.tools.retain(|id| vectorcraft_tools::tool_info(id).is_some());
f.tools.first().is_some_and(|k| seen.insert(k.clone()))
});
self
}
}
@@ -398,6 +416,7 @@ impl Default for UiState {
toolbar_advanced: false,
task_bar: true,
slot_tool: Default::default(),
floating_flyouts: vec![],
status_bar: true,
dock: true,
view: ViewFlags::default(),
+611 -57
View File
@@ -12,6 +12,18 @@ use crate::{VectorcraftApp, icons, widgets};
const PITCH: f32 = 30.0;
const WIDTH: f32 = 48.0;
/// Seconds a press on a tool group's button is held before its flyout opens.
const LONG_PRESS: f64 = 0.35;
/// Width of the grab bar down a flyout's right side: dragging it tears the flyout off.
const BAR: f32 = 10.0;
/// Height of the × at the top of a floating flyout's bar.
const CLOSE: f32 = 14.0;
/// Points the pointer travels on a flyout's bar before the flyout tears off.
const TEAR: f32 = 3.0;
/// Seconds a floating flyout flashes when a press that would open its flyout raises it.
const FLASH: f64 = 0.4;
/// Size of a tool button in a floating flyout's strip.
const STRIP_BUTTON: egui::Vec2 = vec2(32.0, PITCH);
/// The Basic toolbar: (category, slots); each slot is a flyout group (first = default).
pub const BASIC: &[(&str, &[&[&str]])] = &[
@@ -116,6 +128,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
let held: Option<egui::Id> = ui.data(|d| d.get_temp(held_id));
let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None;
// A group torn off into a floating flyout is raised instead of opening its flyout.
let mut raise: Option<&'static str> = None;
let mut i = 0;
while i < all.len() {
if let Some(cat) = all[i].0 {
@@ -167,21 +181,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
Stroke::NONE,
));
}
let held_for = resp
.is_pointer_button_down_on()
.then(|| ui.input(|inp| inp.pointer.press_start_time().map(|s| inp.time - s)))
.flatten();
if slot.len() > 1
&& let Some(seconds) = held_for
&& seconds < 0.35
{
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds));
}
let long_press = held_for.is_some_and(|seconds| seconds >= 0.35);
let press = if slot.len() > 1 { flyout_press(ui, &resp, rect) } else { None };
let alt = ui.input(|inp| inp.modifiers.alt);
if (resp.secondary_clicked() || long_press) && slot.len() > 1 {
open_flyout = Some((slot.clone(), rect));
if long_press {
if press.is_some() {
if is_floating(app, slot[0]) {
raise = Some(slot[0]);
} else {
open_flyout = Some((slot.clone(), rect));
}
if press == Some(FlyoutPress::Primary) {
ui.data_mut(|d| d.insert_temp(held_id, resp.id));
}
} else if resp.clicked() && held == Some(resp.id) {
@@ -200,6 +208,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
i += row.len();
}
if let Some(key) = raise {
raise_floating(ui.ctx(), key);
}
if let Some((slot, rect)) = open_flyout {
app.ui.flyout = Some(0);
ui.data_mut(|d| {
@@ -211,9 +222,46 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
bottom_controls(app, ui, &t);
});
});
// Before the flyout: one torn off this frame floats from the next (its bar mustn't be in two
// layers in one frame).
floating(app, ui.ctx());
flyout(app, ui.ctx());
}
/// How a press on a tool group's button opens its flyout.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum FlyoutPress {
/// A right press (it opens at once, before the release).
Secondary,
/// A primary press on the corner triangle, or one held [`LONG_PRESS`] seconds: its release
/// mustn't pick the button's tool.
Primary,
}
/// Whether this frame's pointer state on a tool button opens its group's flyout. A primary press
/// still short of a long one asks for the frame where it becomes long: egui draws only on input,
/// so a still mouse would otherwise never get there.
fn flyout_press(ui: &Ui, resp: &egui::Response, rect: egui::Rect) -> Option<FlyoutPress> {
if resp.hovered() && ui.input(|inp| inp.pointer.button_pressed(egui::PointerButton::Secondary)) {
return Some(FlyoutPress::Secondary);
}
if !resp.is_pointer_button_down_on() || !ui.input(|inp| inp.pointer.primary_down()) {
return None;
}
// The triangle's corner: the bottom-right third of the button.
let corner = egui::Rect::from_min_max(rect.center() + vec2(6.0, 4.0), rect.max);
let (origin, held) = ui.input(|inp| (inp.pointer.press_origin(), inp.pointer.press_start_time().map(|s| inp.time - s)));
if origin.is_some_and(|p| corner.contains(p)) {
return Some(FlyoutPress::Primary);
}
let held = held.unwrap_or(0.0);
if held >= LONG_PRESS {
return Some(FlyoutPress::Primary);
}
ui.ctx().request_repaint_after_secs((LONG_PRESS - held) as f32);
None
}
/// Open a tool's options (`tool.options`, a double-click on its button): the Gradient tool's are
/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog, a Liquify tool's its Tool
/// Options dialog, the Blend tool's Blend
@@ -334,6 +382,183 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) {
let _ = t;
}
/// Where `window.floatTools` puts a strip (the next one a row lower), right of the toolbar; it's
/// kept on screen and moved by its bar.
const FLOAT_AT: [f32; 2] = [90.0, 100.0];
/// Float the toolbar group (of the current layout) holding `tool` as a strip of tool buttons, put
/// it back in the toolbar, or toggle (`floating` omitted), as a flyout's tear-off bar and the
/// strip's × do (`window.floatTools`). Returns the new state.
pub fn float_group(app: &mut VectorcraftApp, tool: &str, floating: Option<bool>) -> Result<bool, String> {
let Some((_, group)) = slots(app).into_iter().find(|(_, s)| s.len() > 1 && s.contains(&tool)) else {
return Err(format!("{tool} isn't in a toolbar group of several tools"));
};
let key = group.first().copied().unwrap_or_default();
let was = is_floating(app, key);
let on = floating.unwrap_or(!was);
if on && !was {
let n = app.ui.floating_flyouts.len() as f32;
let tools = group.iter().map(|id| id.to_string()).collect();
app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [FLOAT_AT[0], FLOAT_AT[1] + n * (PITCH + 12.0)] });
app.ui.flyout = None;
} else if !on {
app.ui.floating_flyouts.retain(|f| f.tools.first().is_none_or(|k| k != key));
}
Ok(on)
}
/// Whether the group whose first tool is `key` floats as its own panel.
fn is_floating(app: &VectorcraftApp, key: &str) -> bool {
app.ui.floating_flyouts.iter().any(|f| f.tools.first().is_some_and(|k| k == key))
}
/// The area of the floating flyout of the group whose first tool is `key`.
fn floating_area(key: &str) -> egui::Id {
egui::Id::new(("tool-floating", key))
}
/// Bring a floating flyout to the front and flash its border.
fn raise_floating(ctx: &egui::Context, key: &str) {
let id = floating_area(key);
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
let now = ctx.input(|i| i.time);
ctx.data_mut(|d| d.insert_temp(id, now));
}
/// A flyout's frame: drop shadow and border (the accent while it flashes).
fn flyout_frame(t: &Tokens, flash: bool) -> egui::Frame {
egui::Frame::NONE
.fill(t.panel)
.stroke(if flash { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.input_border) })
.shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(90) })
}
/// What a press on a flyout did.
#[derive(Default)]
struct FlyoutInput {
/// The tool row clicked.
chosen: Option<&'static str>,
/// The grab bar's response (its drag tears off or moves the flyout).
bar: Option<egui::Response>,
/// A floating flyout's × was clicked.
close: bool,
}
/// A flyout's tool rows (a floating flyout's strip of tool buttons) with the grab bar down their
/// right side; a floating flyout's bar has a × at its top. The bar's id is the group's, so a drag
/// that tears a flyout off carries on moving the floating flyout it becomes.
fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool) -> FlyoutInput {
let mut out = FlyoutInput::default();
let key = tools.first().map_or("", String::as_str);
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
ui.horizontal(|ui| {
let rows = if floating { ui.horizontal(|ui| tool_strip(ui, t, active, tools)) } else { ui.vertical(|ui| tool_rows(ui, t, active, tools)) };
out.chosen = rows.inner;
let (bar, _) = ui.allocate_exact_size(vec2(BAR, rows.response.rect.height()), Sense::hover());
ui.painter().rect_filled(bar, 0.0, t.tab_strip);
let grip = if floating {
let x = egui::Rect::from_min_size(bar.min, vec2(BAR, CLOSE));
let xr = ui.interact(x, egui::Id::new(("tool-flyout-close", key)), Sense::click());
icons::paint(ui, "x", x.shrink2(vec2(1.0, 3.0)), if xr.hovered() { t.text_strong } else { t.text });
out.close = xr.on_hover_text(tl!("Put back in the toolbar")).clicked();
egui::Rect::from_min_max(bar.min + vec2(0.0, CLOSE), bar.max)
} else {
bar
};
let resp = ui.interact(grip, bar_id(key), Sense::drag());
let half = (grip.height() / 2.0 - 3.0).clamp(0.0, 9.0);
for k in 0..3 {
let x = grip.center().x - 2.0 + k as f32 * 2.0;
ui.painter().line_segment([pos2(x, grip.center().y - half), pos2(x, grip.center().y + half)], Stroke::new(0.6, t.text_disabled));
}
let resp = if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
resp
} else {
let tip = if floating { tl!("Drag to move") } else { tl!("Drag to float as a toolbar") };
resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tip)
};
out.bar = Some(resp);
});
out
}
/// One row per tool: icon, name and shortcut, with a dot by the active tool. Returns the tool clicked.
fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> {
let mut chosen = None;
let w = 250.0;
for id in tools {
let Some(tool) = tool_info(id) else { continue };
let is_active = active == tool.id;
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
if is_active {
ui.painter().rect_filled(egui::Rect::from_center_size(r.left_center() + vec2(12.0, 0.0), vec2(5.0, 5.0)), 0.0, t.text);
}
let ir = egui::Rect::from_min_size(r.min + vec2(24.0, 6.0), vec2(18.0, 18.0));
icons::paint(ui, icons::tool_icon(tool.icon), ir, t.icon);
let color = if is_active { t.flyout_active } else { t.text_strong };
ui.painter().text(r.left_center() + vec2(52.0, 0.0), egui::Align2::LEFT_CENTER, tl!(tool.label), egui::FontId::proportional(13.0), color);
if let Some(sc) = crate::shortcut_editor::tool_shortcut(tool.id) {
ui.painter().text(
r.right_center() - vec2(18.0, 0.0),
egui::Align2::RIGHT_CENTER,
format!("({sc})"),
egui::FontId::proportional(13.0),
color,
);
}
if resp.clicked() {
chosen = Some(tool.id);
}
}
chosen
}
/// The grab bar of the group whose first tool is `key` (one id, flyout or floating, so a drag
/// that tears the flyout off carries on moving it).
fn bar_id(key: &str) -> egui::Id {
egui::Id::new(("tool-flyout-bar", key))
}
/// Where the pointer holds a floating flyout's bar, from the flyout's corner, while it's dragged.
fn grab_id(key: &str) -> egui::Id {
bar_id(key).with("grab")
}
/// One icon button per tool, the active one lit, with its name and shortcut on hover. Returns the
/// tool clicked.
fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> {
let mut chosen = None;
for tool in tools.iter().filter_map(|id| tool_info(id)) {
let (r, resp) = ui.allocate_exact_size(STRIP_BUTTON, Sense::click());
let well = r.shrink(1.0);
if active == tool.id {
ui.painter().rect_filled(well, CornerRadius::same(1), t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(well, CornerRadius::same(1), t.hover);
}
let ir = egui::Rect::from_center_size(r.center(), vec2(18.0, 18.0));
icons::paint(ui, icons::tool_icon(tool.icon), ir, if active == tool.id { t.text_strong } else { t.icon });
if resp.on_hover_text(tip(tool)).clicked() {
chosen = Some(tool.id);
}
}
chosen
}
/// The width of a floating flyout's strip of `tools`, its bar included.
fn strip_width(tools: &[String]) -> f32 {
tools.iter().filter(|id| tool_info(id).is_some()).count() as f32 * STRIP_BUTTON.x + BAR
}
/// How far the pointer has moved since the press that's down, if one is.
fn press_travel(ctx: &egui::Context) -> Option<egui::Vec2> {
ctx.input(|i| i.pointer.press_origin().zip(i.pointer.interact_pos()).map(|(a, b)| b - a))
}
fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.ui.flyout.is_none() {
return;
@@ -346,54 +571,33 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
let anchor: egui::Rect =
ctx.data(|d| d.get_temp(egui::Id::new("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0)));
let t = Tokens::get(ctx);
let mut chosen = None;
let active = app.session.tool_id();
let mut input = FlyoutInput::default();
let resp = egui::Area::new(egui::Id::new("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show(
ctx,
|ui| {
egui::Frame::NONE
.fill(t.panel)
.stroke(Stroke::new(1.0, t.input_border))
.shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(90) })
.show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
let w = 250.0;
for id in &tools {
let Some(tool) = tool_info(id) else { continue };
let active = app.session.tool_id() == tool.id;
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
if active {
ui.painter().rect_filled(egui::Rect::from_center_size(r.left_center() + vec2(12.0, 0.0), vec2(5.0, 5.0)), 0.0, t.text);
}
let ir = egui::Rect::from_min_size(r.min + vec2(24.0, 6.0), vec2(18.0, 18.0));
icons::paint(ui, icons::tool_icon(tool.icon), ir, t.icon);
let color = if active { t.flyout_active } else { t.text_strong };
ui.painter().text(
r.left_center() + vec2(52.0, 0.0),
egui::Align2::LEFT_CENTER,
tl!(tool.label),
egui::FontId::proportional(13.0),
color,
);
if let Some(sc) = crate::shortcut_editor::tool_shortcut(tool.id) {
ui.painter().text(
r.right_center() - vec2(18.0, 0.0),
egui::Align2::RIGHT_CENTER,
format!("({sc})"),
egui::FontId::proportional(13.0),
color,
);
}
if resp.clicked() {
chosen = Some(tool.id);
}
}
});
flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false));
},
);
if let Some(id) = chosen {
// Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the
// end of the long press that opened the flyout): it floats where the pointer is.
let at = ctx.input(|i| i.pointer.interact_pos());
let released_on_bar = input.bar.as_ref().is_some_and(|b| ctx.input(|i| i.pointer.primary_released()) && at.is_some_and(|p| b.rect.contains(p)));
let dragged_off = input.bar.as_ref().is_some_and(egui::Response::dragged) && press_travel(ctx).is_some_and(|d| d.length() > TEAR);
if released_on_bar || dragged_off {
// The strip is far narrower than the flyout: it floats with its bar under the pointer.
let at = at.unwrap_or(resp.response.rect.right_center());
// (Past the frame's 1 pt border.)
let grab = vec2(1.0 + strip_width(&tools) - BAR / 2.0, 1.0 + CLOSE + 4.0);
let p = at - grab;
if let Some(key) = tools.first() {
ctx.data_mut(|m| m.insert_temp(grab_id(key), grab));
}
app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [p.x, p.y] });
app.ui.flyout = None;
return;
}
if let Some(id) = input.chosen {
app.select_tool(id);
} else if resp.response.clicked_elsewhere() && !ctx.input(|i| i.pointer.interact_pos().is_some_and(|p| anchor.contains(p))) {
// A click on the flyout's own tool button (the right-click that opened it, or the end of a
@@ -403,6 +607,61 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
let _ = theme::semibold;
}
/// The flyouts torn off the toolbar, each floating where its bar was dragged; its × puts it back.
fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.ui.floating_flyouts.is_empty() {
return;
}
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
let now = ctx.input(|i| i.time);
let active = app.session.tool_id();
let mut chosen = None;
let mut closed = None;
for (k, f) in app.ui.floating_flyouts.iter_mut().enumerate() {
let Some(key) = f.tools.first() else { continue };
let id = floating_area(key);
let since = ctx.data(|d| d.get_temp::<f64>(id)).map(|t0| now - t0);
let flash = since.is_some_and(|s| (0.0..FLASH).contains(&s));
if let Some(s) = since.filter(|_| flash) {
ctx.request_repaint_after_secs((FLASH - s) as f32);
}
// Kept on screen (a smaller window, or a position saved on a bigger one).
let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(strip_width(&f.tools), PITCH), |r| r.size());
let [x, y] = f.pos;
let (x, y) = if x.is_finite() && y.is_finite() { (x, y) } else { (screen.left() + 60.0, screen.top() + 100.0) };
let pos = pos2(x.min(screen.right() - size.x).max(screen.left()), y.min(screen.bottom() - size.y).max(screen.top()));
let mut input = FlyoutInput::default();
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| {
flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true));
});
// Dragging the bar keeps the flyout where the pointer holds it (by an offset rather than
// each frame's motion, which a just torn-off flyout's first, unseen frame would drop).
let grab = grab_id(key);
if ctx.is_being_dragged(bar_id(key))
&& let Some(at) = ctx.input(|i| i.pointer.interact_pos())
{
let off = ctx.data_mut(|m| *m.get_temp_mut_or_insert_with(grab, || at - pos));
let p = at - off;
if p.x.is_finite() && p.y.is_finite() {
f.pos = [p.x, p.y];
}
} else if !ctx.input(|i| i.pointer.any_down()) {
ctx.data_mut(|m| m.remove::<egui::Vec2>(grab));
}
if input.close {
closed = Some(k);
}
chosen = chosen.or(input.chosen);
}
if let Some(k) = closed.filter(|k| *k < app.ui.floating_flyouts.len()) {
app.ui.floating_flyouts.remove(k);
}
if let Some(id) = chosen {
app.select_tool(id);
}
}
/// The Puppet Warp tool's Control bar: Expand (how far the mesh reaches past the art), Show Mesh
/// and Select All Pins.
fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -607,6 +866,78 @@ pub(crate) mod tests {
frame(&mut app, &ctx, 4.05, vec![c(false)]);
assert_eq!((app.session.tool_id(), app.ui.flyout), ("roundedRectangle", None));
}
/// The Shapes group's button (Selection, Direct Selection, Lasso, then Rectangle).
fn shapes_button(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Rect {
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
frame(app, ctx, 0.0, vec![])[3]
}
#[test]
fn a_held_press_asks_for_the_frame_that_opens_the_flyout() {
// egui draws only on input: a still press must schedule the frame where it becomes long.
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let at = shapes_button(&mut app, &ctx).center();
let p = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() };
frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), p]);
// The frames egui runs after the input settle; then nothing moves.
frame(&mut app, &ctx, 1.02, vec![]);
let input =
egui::RawInput { time: Some(1.04), screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))), ..Default::default() };
let out = ctx.run_ui(input, |ui| show(&mut app, ui));
let delay = out.viewport_output.get(&egui::ViewportId::ROOT).unwrap().repaint_delay;
assert!(delay <= std::time::Duration::from_secs_f64(LONG_PRESS), "repaint in {delay:?}");
assert!(app.ui.flyout.is_none(), "not open before the press is long");
}
#[test]
fn pressing_the_corner_triangle_opens_the_flyout_at_once() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let button = shapes_button(&mut app, &ctx);
let at = button.right_bottom() - vec2(4.0, 4.0);
let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), p(true)]);
assert!(app.ui.flyout.is_some(), "the press on the triangle opens it");
frame(&mut app, &ctx, 1.05, vec![p(false)]);
frame(&mut app, &ctx, 1.1, vec![]);
assert_eq!((app.session.tool_id(), app.ui.flyout.is_some()), ("selection", true), "the release neither closes it nor picks the tool");
// A plain click in the middle of the button still just picks its tool.
let mid = button.center();
let c = |pressed| Event::PointerButton { pos: mid, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, 2.0, vec![Event::PointerMoved(mid), c(true)]);
frame(&mut app, &ctx, 2.05, vec![c(false)]);
assert_eq!((app.session.tool_id(), app.ui.flyout), ("rectangle", None));
}
#[test]
fn the_flyout_opens_on_the_right_press_even_when_its_tool_is_active() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let at = shapes_button(&mut app, &ctx).center();
app.select_tool("rectangle");
for (k, t0) in [1.0, 3.0, 5.0].into_iter().enumerate() {
let r = |pressed| Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, t0, vec![Event::PointerMoved(at), r(true)]);
assert!(app.ui.flyout.is_some(), "right press #{k} opens it before the release");
frame(&mut app, &ctx, t0 + 0.05, vec![r(false)]);
assert!(app.ui.flyout.is_some(), "right press #{k} leaves it open");
// A click away closes it; then try again.
let away = egui::pos2(300.0, 1000.0);
let c = |pressed| Event::PointerButton { pos: away, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, t0 + 1.0, vec![Event::PointerMoved(away), c(true)]);
frame(&mut app, &ctx, t0 + 1.05, vec![c(false)]);
assert!(app.ui.flyout.is_none());
}
// A long press on the active tool's button opens it too.
let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, 8.0, vec![Event::PointerMoved(at), p(true)]);
frame(&mut app, &ctx, 8.0 + LONG_PRESS + 0.01, vec![]);
assert!(app.ui.flyout.is_some(), "a long press on the active tool opens it");
assert_eq!(app.session.tool_id(), "rectangle");
}
#[test]
fn type_button_long_press_selects_vertical_type_in_both_layouts() {
for advanced in [false, true] {
@@ -636,4 +967,227 @@ pub(crate) mod tests {
assert_eq!(app.ui.flyout, None);
}
}
/// A press at `from`, dragged through `to` (one frame each), then released.
fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: &[Pos2]) {
let b = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, ctx, time, vec![Event::PointerMoved(from), b(from, true)]);
for (k, p) in to.iter().enumerate() {
frame(app, ctx, time + 0.05 * (k + 1) as f64, vec![Event::PointerMoved(*p)]);
}
let end = to.last().copied().unwrap_or(from);
frame(app, ctx, time + 0.05 * (to.len() + 1) as f64, vec![b(end, false)]);
frame(app, ctx, time + 0.05 * (to.len() + 2) as f64, vec![]);
}
/// A click at `at`.
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2, button: PointerButton) {
let b = |pressed| Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() };
frame(app, ctx, time, vec![Event::PointerMoved(at), b(true)]);
frame(app, ctx, time + 0.05, vec![b(false)]);
frame(app, ctx, time + 0.1, vec![]);
}
#[test]
fn a_flyout_dragged_by_its_bar_floats_until_its_close_box_puts_it_back() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let at = shapes_button(&mut app, &ctx).center();
click(&mut app, &ctx, 1.0, at, PointerButton::Secondary);
let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).expect("the flyout is shown");
// Its grab bar runs down its right side: dragging it tears the flyout off.
let bar = pos2(menu.right() - BAR / 2.0, menu.center().y);
drag(&mut app, &ctx, 2.0, bar, &[bar + vec2(20.0, 0.0), bar + vec2(60.0, 40.0)]);
assert_eq!(app.ui.flyout, None, "the flyout became the floating one");
let [f] = app.ui.floating_flyouts.as_slice() else { panic!("one floating flyout: {:?}", app.ui.floating_flyouts) };
assert_eq!(f.tools.first().map(String::as_str), Some("rectangle"));
let float = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).expect("the floating flyout is shown");
// A strip of its nine tools' buttons, its bar under the pointer.
let drop = bar + vec2(60.0, 40.0);
assert!(float.height() < 40.0 && float.width() > 9.0 * STRIP_BUTTON.x, "a one-row strip: {float:?}");
assert!(
(float.right() - 1.0 - BAR / 2.0 - drop.x).abs() < 1.0 && float.y_range().contains(drop.y),
"its bar is under the pointer: {float:?}"
);
// The same bar moves it.
let bar = pos2(float.right() - 1.0 - BAR / 2.0, float.bottom() - 6.0);
drag(&mut app, &ctx, 3.0, bar, &[bar + vec2(0.0, 20.0), bar + vec2(-10.0, 100.0)]);
let moved = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).unwrap();
assert!((moved.min - (float.min + vec2(-10.0, 100.0))).length() < 2.0, "moved to {moved:?} from {float:?}");
// Picking a tool in it leaves it open.
click(&mut app, &ctx, 4.0, pos2(moved.left() + STRIP_BUTTON.x * 1.5, moved.center().y), PointerButton::Primary);
assert_eq!((app.session.tool_id(), app.ui.floating_flyouts.len()), ("roundedRectangle", 1));
// While it floats, the presses that open the group's flyout raise it instead.
click(&mut app, &ctx, 5.0, at, PointerButton::Secondary);
assert_eq!(app.ui.flyout, None, "no flyout beside the floating one");
assert!(ctx.data(|d| d.get_temp::<f64>(floating_area("rectangle"))).is_some(), "the floating flyout was raised");
let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, 6.0, vec![Event::PointerMoved(at), p(true)]);
frame(&mut app, &ctx, 6.0 + LONG_PRESS + 0.01, vec![]);
frame(&mut app, &ctx, 6.5, vec![p(false)]);
assert_eq!((app.ui.flyout, app.session.tool_id()), (None, "roundedRectangle"), "a long press neither opens the flyout nor picks the tool");
// The × at the top of the bar puts it back: the group opens as a flyout again.
click(&mut app, &ctx, 7.0, pos2(moved.right() - BAR / 2.0, moved.top() + CLOSE / 2.0), PointerButton::Primary);
assert!(app.ui.floating_flyouts.is_empty());
click(&mut app, &ctx, 8.0, at, PointerButton::Secondary);
assert!(app.ui.flyout.is_some());
}
#[test]
fn a_click_on_a_flyouts_bar_tears_it_off() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let at = shapes_button(&mut app, &ctx).center();
click(&mut app, &ctx, 1.0, at, PointerButton::Secondary);
let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).unwrap();
let bar = pos2(menu.right() - BAR / 2.0, menu.center().y);
click(&mut app, &ctx, 2.0, bar, PointerButton::Primary);
frame(&mut app, &ctx, 2.2, vec![]);
assert_eq!(app.ui.flyout, None);
assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.tools[0].as_str()).collect::<Vec<_>>(), ["rectangle"]);
let float = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).expect("the floating flyout is shown");
assert!(float.contains(bar), "its bar is under the pointer: {float:?}");
assert_eq!(app.session.tool_id(), "selection", "no tool picked");
}
#[test]
fn releasing_a_long_press_over_the_flyouts_bar_tears_it_off() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let at = shapes_button(&mut app, &ctx).center();
let b = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), b(at, true)]);
frame(&mut app, &ctx, 1.0 + LONG_PRESS + 0.01, vec![]);
frame(&mut app, &ctx, 1.45, vec![]);
let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).expect("the long press opens the flyout");
let bar = pos2(menu.right() - BAR / 2.0, menu.center().y);
frame(&mut app, &ctx, 1.5, vec![Event::PointerMoved(bar)]);
frame(&mut app, &ctx, 1.55, vec![b(bar, false)]);
frame(&mut app, &ctx, 1.6, vec![]);
assert_eq!((app.ui.flyout, app.ui.floating_flyouts.len(), app.session.tool_id()), (None, 1, "selection"));
}
#[test]
fn window_float_tools_floats_and_puts_back_a_group() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let check = |app: &VectorcraftApp| crate::menus::checked(app, "window.floatTools", &json!({"tool": "star"}));
assert_eq!(app.run("window.floatTools", json!({"tool": "star"})).unwrap(), json!(true));
let [f] = app.ui.floating_flyouts.as_slice() else { panic!("one strip: {:?}", app.ui.floating_flyouts) };
assert_eq!(f.tools.first().map(String::as_str), Some("rectangle"), "the Basic toolbar's Shapes group");
assert_eq!(check(&app), Some(true));
// Already floating: stays where it is.
app.ui.floating_flyouts[0].pos = [300.0, 400.0];
assert_eq!(app.run("window.floatTools", json!({"tool": "rectangle", "floating": true})).unwrap(), json!(true));
assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.pos).collect::<Vec<_>>(), [[300.0, 400.0]]);
// Omitted toggles it back.
assert_eq!(app.run("window.floatTools", json!({"tool": "star"})).unwrap(), json!(false));
assert!(app.ui.floating_flyouts.is_empty() && check(&app) == Some(false));
assert_eq!(app.run("window.floatTools", json!({"tool": "pen", "floating": false})).unwrap(), json!(false));
// A tool alone in its slot, an unknown tool, a bad flag.
assert!(app.run("window.floatTools", json!({"tool": "ellipse"})).is_err());
assert!(app.run("window.floatTools", json!({"tool": "nope"})).is_err());
assert!(app.run("window.floatTools", json!({})).is_err());
assert!(app.run("window.floatTools", json!({"tool": "pen", "floating": "yes"})).is_err());
assert!(app.ui.floating_flyouts.is_empty());
}
#[test]
fn workspaces_keep_floating_flyouts_and_reset_puts_them_back() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("window.floatTools", json!({"tool": "pen"})).unwrap();
app.run("window.workspace.new", json!({"name": "Strips"})).unwrap();
app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
assert!(app.ui.floating_flyouts.is_empty(), "the built-in workspaces float none");
app.run("window.workspace", json!({"name": "Strips"})).unwrap();
assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.tools[0].as_str()).collect::<Vec<_>>(), ["pen"]);
// Workspaces saved before floating strips load without any.
let mut old = serde_json::to_value(crate::workspaces::Workspace::default()).unwrap();
old.as_object_mut().unwrap().remove("floatingFlyouts");
assert!(serde_json::from_value::<crate::workspaces::Workspace>(old).unwrap().floating_flyouts.is_empty());
}
#[test]
fn loaded_floating_flyouts_keep_known_tools_one_strip_per_group() {
let f = |tools: &[&str]| crate::state::FloatingFlyout { tools: tools.iter().map(|s| s.to_string()).collect(), pos: [0.0, 0.0] };
let ui = crate::state::UiState {
floating_flyouts: vec![f(&["pen", "nope", "addAnchor"]), f(&["pen", "addAnchor"]), f(&["nope"]), f(&[])],
..Default::default()
};
let ui = ui.sanitized();
assert_eq!(ui.floating_flyouts.iter().map(|f| f.tools.join(",")).collect::<Vec<_>>(), ["pen,addAnchor"]);
}
#[test]
fn floating_flyouts_survive_saved_preferences_and_stay_on_screen() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools: vec!["pen".into(), "addAnchor".into()], pos: [5000.0, 10.0] });
let saved = serde_json::to_value(&app.ui).unwrap();
let back: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap();
assert_eq!(back.floating_flyouts.len(), 1);
// Preferences saved before floating flyouts load without any.
let mut old = saved;
old.as_object_mut().unwrap().remove("floating_flyouts");
assert!(serde_json::from_value::<crate::state::UiState>(old).unwrap().floating_flyouts.is_empty());
let ctx = egui::Context::default();
shapes_button(&mut app, &ctx);
frame(&mut app, &ctx, 1.0, vec![]);
let r = ctx.memory(|m| m.area_rect(floating_area("pen"))).unwrap();
assert!(r.right() <= 400.5 && r.left() >= 0.0 && r.top() >= 0.0, "on screen: {r:?}");
// A position that isn't a number puts it near the toolbar.
app.ui.floating_flyouts[0].pos = [f32::NAN, f32::INFINITY];
frame(&mut app, &ctx, 2.0, vec![]);
let r = ctx.memory(|m| m.area_rect(floating_area("pen"))).unwrap();
assert!(r.min.x.is_finite() && r.min.y.is_finite() && r.left() >= 0.0, "on screen: {r:?}");
}
/// Hover `at` for two seconds, coming from elsewhere: the texts painted meanwhile.
fn hovered_texts(app: &mut VectorcraftApp, ctx: &egui::Context, t0: f64, at: Pos2) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let mut shown = vec![];
for i in 0..20 {
let events = match i {
0 => vec![Event::PointerGone],
1 => vec![Event::PointerMoved(at)],
_ => vec![],
};
let time = t0 + f64::from(i) * 0.1;
let raw = egui::RawInput {
events,
time: Some(time),
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))),
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
crate::prefs_dialog::apply_runtime(app, ui.ctx());
show(app, ui);
});
out.textures_delta.clear();
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
}
shown
}
#[test]
fn a_floating_flyouts_buttons_have_tool_tips_when_tool_tips_are_on() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let tools: Vec<String> = ["rectangle", "roundedRectangle", "star"].map(String::from).to_vec();
app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [150.0, 600.0] });
let ctx = egui::Context::default();
shapes_button(&mut app, &ctx);
frame(&mut app, &ctx, 1.0, vec![]);
let strip = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).unwrap();
let star = pos2(strip.left() + STRIP_BUTTON.x * 2.5, strip.center().y);
let tip = tl!("Star Tool").to_string();
assert!(hovered_texts(&mut app, &ctx, 10.0, star).contains(&tip), "on by default");
app.session.prefs.show_tool_tips = false;
assert!(!hovered_texts(&mut app, &ctx, 20.0, star).contains(&tip), "off: no tool tip");
app.session.prefs.show_tool_tips = true;
assert!(hovered_texts(&mut app, &ctx, 30.0, star).contains(&tip), "on again");
}
}
+5 -1
View File
@@ -7,7 +7,7 @@ use std::sync::atomic::Ordering;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::state::{Dialog, DockTab, UiState};
use crate::state::{Dialog, DockTab, FloatingFlyout, UiState};
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
@@ -27,6 +27,8 @@ pub struct Workspace {
pub dock_collapsed: bool,
pub open_panel: Option<String>,
pub status_bar: bool,
/// Tool groups torn off the toolbar into floating strips (built-in workspaces: none).
pub floating_flyouts: Vec<FloatingFlyout>,
}
impl Default for Workspace {
@@ -120,6 +122,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
dock_collapsed: ui.dock_collapsed,
open_panel: ui.open_panel.clone(),
status_bar: ui.status_bar,
floating_flyouts: ui.floating_flyouts.clone(),
}
}
@@ -136,6 +139,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
ui.dock_collapsed = w.dock_collapsed;
ui.open_panel = w.open_panel.clone();
ui.status_bar = w.status_bar;
ui.floating_flyouts = w.floating_flyouts.clone();
ui.flyout = None;
ui.workspace = w.name.clone();
}
+8
View File
@@ -192,6 +192,14 @@ pop the panel out next to the column like the other icon panels (`ui.dock_collap
`ui.inspect`); expanding with one popped out shows its tab. The state is saved with the preferences and in user
workspaces; the built-in workspaces expand the dock.
Floating tool groups: dragging or clicking the tear-off bar down a tool group's flyout (or releasing the long press
that opened it over the bar) floats the group as a strip of tool buttons, moved by the same bar and put back in the
toolbar by the × at its top; `window.floatTools {tool, floating?}` does the same for the group of the current
layout holding `tool` (omitted toggles; a tool alone in its slot is an error). While a group floats, the presses
that open its flyout raise the strip instead. The strips (`ui.floating_flyouts` in `ui.inspect`: the group's tools
and the strip's top-left corner) are saved with the preferences and in user workspaces; the built-in workspaces
float none.
Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTransparency` dialog for the selection
(fields `preset`: a preset name, setting it loads that preset's options; the option keys of
`object.flattenTransparency`: `balance` 0–100, `lineArtPpi` and `gradientPpi` 1–2400, `textToOutlines`,