mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-15 11:46:54 -04:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1706e84829 | ||
|
|
696d9f619b | ||
|
|
3966b0cce5 | ||
|
|
b86d19feed | ||
|
|
f155bc5514 | ||
|
|
e6914861f1 | ||
|
|
60439d88b9 | ||
|
|
2366000e19 |
No files matched your search
+5
-5
@@ -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); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -132,7 +132,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text.
|
||||
- **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).
|
||||
@@ -140,8 +140,8 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **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}`).
|
||||
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
|
||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units, shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups; Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
|
||||
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
@@ -168,7 +168,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
|
||||
@@ -78,7 +78,7 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaOptions, CharAlign, CharPosition, CharStyle, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathEffect,
|
||||
AreaOptions, CharAlign, CharPosition, CharStyle, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect,
|
||||
ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape,
|
||||
};
|
||||
pub use vectorcraft_color as color;
|
||||
@@ -191,7 +191,10 @@ impl Unit {
|
||||
}
|
||||
/// [`Unit::format`] without the suffix (`12.5`), for narrow fields.
|
||||
pub fn number(self, pt: f64) -> String {
|
||||
let s = format!("{:.3}", self.from_pt(pt));
|
||||
// Three decimals for the small units (`595.276 pt`), four for the large ones (`8.2677 in`,
|
||||
// `35.2778 mm`, the `0.0078 in` stroke preset); trailing zeros are trimmed (`1 pt` is `1`).
|
||||
let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas) { 3 } else { 4 };
|
||||
let s = format!("{:.*}", decimals, self.from_pt(pt));
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ fn readouts_show_two_decimals() {
|
||||
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
|
||||
assert_eq!(Unit::Millimeters.readout(72.0), "25.40 mm");
|
||||
assert_eq!(Unit::Inches.readout(-36.0), "-0.50 in");
|
||||
assert_eq!(Unit::Millimeters.number(1.0), "0.353");
|
||||
assert_eq!(Unit::Points.number(-0.0001), "0");
|
||||
assert_eq!(Unit::Millimeters.number(1.0), "0.3528");
|
||||
// Four decimals for the large units, three for the small ones.
|
||||
assert_eq!(Unit::Inches.number(0.5616), "0.0078");
|
||||
assert_eq!((Unit::Inches.number(595.2756), Unit::Points.number(595.2756)), ("8.2677".into(), "595.276".into()));
|
||||
assert_eq!(Unit::Points.number(-0.00001), "0");
|
||||
}
|
||||
+105
-2
@@ -539,6 +539,36 @@ impl PathEffect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on a Path Options › Align to Path: which height of the type runs along the path.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PathAlign {
|
||||
/// The font's top edge: the type hangs below the path.
|
||||
Ascender,
|
||||
/// The font's bottom edge: the type stands above the path.
|
||||
Descender,
|
||||
/// Halfway between the ascender and the descender.
|
||||
Center,
|
||||
/// The baseline (the default).
|
||||
#[default]
|
||||
Baseline,
|
||||
}
|
||||
|
||||
impl PathAlign {
|
||||
pub const ALL: [PathAlign; 4] = [PathAlign::Ascender, PathAlign::Descender, PathAlign::Center, PathAlign::Baseline];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
PathAlign::Ascender => "ascender",
|
||||
PathAlign::Descender => "descender",
|
||||
PathAlign::Center => "center",
|
||||
PathAlign::Baseline => "baseline",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Area Type Options: rows and columns, gutters, inset and first baseline of area type.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -583,8 +613,48 @@ pub enum TextKind {
|
||||
Point,
|
||||
/// Area type flowed inside `frame` (document coordinates, untransformed by `xf`).
|
||||
Area { frame: PathData },
|
||||
/// Type on a path, starting at `start` (0..1 of the path length).
|
||||
OnPath { path: PathData, start: f64 },
|
||||
/// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the
|
||||
/// path length; no `end`: the end of the path, or once round a closed path). See
|
||||
/// [`TextKind::path_span`].
|
||||
OnPath {
|
||||
path: PathData,
|
||||
start: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
end: Option<f64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TextKind {
|
||||
/// Type on a path's span: from its start to its end bracket, as fractions of its path's length.
|
||||
/// Round a closed path the end may be past 1 (the span runs on past the path's start), a full
|
||||
/// turn without an end or with the end at the start. None for other type.
|
||||
pub fn path_span(&self) -> Option<(f64, f64)> {
|
||||
let TextKind::OnPath { path, start, end } = self else { return None };
|
||||
let s = fraction(*start);
|
||||
Some(if path.is_closed() {
|
||||
let run = end.map_or(0.0, |e| (fraction(e) - s).rem_euclid(1.0));
|
||||
(s, s + if run < 1e-9 { 1.0 } else { run })
|
||||
} else {
|
||||
(s, end.map_or(1.0, |e| fraction(e).max(s)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `x` as a fraction of a path's length (0..1; 0 when not finite).
|
||||
fn fraction(x: f64) -> f64 {
|
||||
if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// `path` run the other way, each point as far from its new start as it was from its old end
|
||||
/// (the subpaths in reverse order, a closed one keeping its first anchor first).
|
||||
fn reverse_from_end(path: &mut PathData) {
|
||||
path.subpaths.reverse();
|
||||
for sp in &mut path.subpaths {
|
||||
sp.reverse();
|
||||
if sp.closed && !sp.anchors.is_empty() {
|
||||
sp.anchors.rotate_right(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A text object. `runs` split into paragraphs at `\n`.
|
||||
@@ -605,6 +675,14 @@ pub struct TextObject {
|
||||
/// Type on a Path effect (type on a path only).
|
||||
#[serde(default, rename = "pathEffect", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub path_effect: PathEffect,
|
||||
/// Type on a Path Options › Align to Path (type on a path only).
|
||||
#[serde(default, rename = "pathAlign", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub path_align: PathAlign,
|
||||
/// Type on a Path Options › Spacing in points (type on a path only): glyphs are spaced as if
|
||||
/// set this far above the path, which closes them up round the outside of a curve and opens
|
||||
/// them up round the inside.
|
||||
#[serde(default, rename = "pathSpacing", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub path_spacing: f64,
|
||||
/// Wrap objects above this area type, resolved by the engine after each edit (text space).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub wrap: Vec<WrapShape>,
|
||||
@@ -623,6 +701,8 @@ impl TextObject {
|
||||
para: ParaStyle::default(),
|
||||
area: AreaOptions::default(),
|
||||
path_effect: PathEffect::default(),
|
||||
path_align: PathAlign::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
cached_bounds: None,
|
||||
}
|
||||
@@ -652,6 +732,29 @@ impl TextObject {
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
self.xf = a * self.xf;
|
||||
}
|
||||
/// Type on a path's path in document space; None for other type.
|
||||
pub fn type_path(&self) -> Option<PathData> {
|
||||
match &self.kind {
|
||||
TextKind::OnPath { path, .. } => Some(path.transformed(self.xf)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its
|
||||
/// centre bracket dragged across the path): the path runs the other way and the brackets swap
|
||||
/// ends, so the type keeps its stretch of the path. False, changing nothing, for other type.
|
||||
pub fn flip_on_path(&mut self) -> bool {
|
||||
let Some((s, e)) = self.kind.path_span() else { return false };
|
||||
let TextKind::OnPath { path, start, end } = &mut self.kind else { return false };
|
||||
reverse_from_end(path);
|
||||
if path.is_closed() {
|
||||
*start = (1.0 - e).rem_euclid(1.0);
|
||||
*end = (e - s < 1.0 - 1e-9).then(|| (1.0 - s).rem_euclid(1.0));
|
||||
} else {
|
||||
*start = 1.0 - e;
|
||||
*end = (s > 1e-9).then_some(1.0 - s);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Area type's frame (the type area) in document space; None for other type.
|
||||
pub fn area_frame(&self) -> Option<PathData> {
|
||||
match &self.kind {
|
||||
|
||||
@@ -453,10 +453,10 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
let start = match point_param(p, "at") {
|
||||
Some(at) if on_path => vectorcraft_text::path_fraction_at(&path.to_bezpath(), at).0,
|
||||
Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
};
|
||||
let kind = if on_path { TextKind::OnPath { path: path.clone(), start } } else { TextKind::Area { frame: path.clone() } };
|
||||
let kind = if on_path { TextKind::OnPath { path: path.clone(), start, end: None } } else { TextKind::Area { frame: path.clone() } };
|
||||
let mut t = TextObject {
|
||||
vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false),
|
||||
kind,
|
||||
@@ -465,6 +465,8 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
para: super::create::new_type_para(),
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
cached_bounds: None,
|
||||
};
|
||||
@@ -595,6 +597,8 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
para: Default::default(),
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
cached_bounds: None,
|
||||
};
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Type on a Path Options…",
|
||||
["Type", "Type on a Path"],
|
||||
None,
|
||||
"{start?: 0..1 (fraction of the path length), flip?: bool (reverse the path), effect?: rainbow|skew|3dRibbon|stairStep|gravity, ids?}",
|
||||
"{ids?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
|
||||
has_selection,
|
||||
path_options
|
||||
),
|
||||
@@ -343,38 +343,95 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
/// The options `type.pathOptions` sets; with none of them given it queries.
|
||||
const PATH_OPTIONS: [&str; 6] = ["start", "end", "flip", "effect", "alignToPath", "spacing"];
|
||||
|
||||
/// Type on a path's text, if `n` is type on a path.
|
||||
fn path_text(n: Option<&vectorcraft_doc::Node>) -> Option<&TextObject> {
|
||||
match n.map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::OnPath { .. }) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on a Path Options of `t`, as `type.pathOptions` takes them (`flip` is an action: false).
|
||||
fn path_options_of(t: &TextObject) -> Value {
|
||||
let (start, end) = match &t.kind {
|
||||
TextKind::OnPath { start, end, .. } => (*start, *end),
|
||||
_ => (0.0, None),
|
||||
};
|
||||
json!({
|
||||
"start": start,
|
||||
"end": end,
|
||||
"flip": false,
|
||||
"effect": t.path_effect.id(),
|
||||
"alignToPath": t.path_align.id(),
|
||||
"spacing": t.path_spacing,
|
||||
})
|
||||
}
|
||||
|
||||
fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.pathOptions";
|
||||
let ids = texts(s, p, C)?;
|
||||
let start = p.get("start").and_then(Value::as_f64);
|
||||
let ids: Vec<NodeId> = {
|
||||
let d = &s.doc()?.doc;
|
||||
texts(s, p, C)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect()
|
||||
};
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select type on a path"))?;
|
||||
let options = |s: &Session| -> Result<Value> {
|
||||
let t = path_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select type on a path"))?;
|
||||
Ok(path_options_of(t))
|
||||
};
|
||||
if !PATH_OPTIONS.iter().any(|k| p.get(k).is_some()) {
|
||||
return options(s);
|
||||
}
|
||||
// A bracket: a fraction of the path's length.
|
||||
let fraction = |k: &str| -> Result<Option<f64>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| Some(x.clamp(0.0, 1.0))).ok_or_else(|| bad(C, format!("`{k}` is a number 0..1"))),
|
||||
}
|
||||
};
|
||||
let start = fraction("start")?;
|
||||
// `end: null` puts the end bracket back at the end of the path.
|
||||
let end = p.get("end").map(|_| fraction("end")).transpose()?;
|
||||
let flip = bool_or(p, "flip", false);
|
||||
let effect = match str_param(p, "effect") {
|
||||
Some(e) => Some(vectorcraft_doc::PathEffect::parse(e).ok_or_else(|| bad(C, format!("unknown effect `{e}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let n = s.edit("Type on a Path Options", |d, _| {
|
||||
let mut n = 0;
|
||||
let align = match str_param(p, "alignToPath") {
|
||||
Some(a) => Some(vectorcraft_doc::PathAlign::parse(a).ok_or_else(|| bad(C, format!("unknown alignToPath `{a}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let spacing = p.get("spacing").and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(-1000.0, 1000.0));
|
||||
s.edit("Type on a Path Options", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let TextKind::OnPath { path, start: st } = &mut t.kind else { continue };
|
||||
let TextKind::OnPath { start: st, end: en, .. } = &mut t.kind else { continue };
|
||||
if let Some(v) = start {
|
||||
*st = v.clamp(0.0, 1.0);
|
||||
*st = v;
|
||||
}
|
||||
if let Some(v) = end {
|
||||
*en = v;
|
||||
}
|
||||
// After the brackets: they are given on the path as it runs before the flip.
|
||||
if flip {
|
||||
path.reverse();
|
||||
t.flip_on_path();
|
||||
}
|
||||
if let Some(e) = effect {
|
||||
t.path_effect = e;
|
||||
}
|
||||
n += 1;
|
||||
if let Some(a) = align {
|
||||
t.path_align = a;
|
||||
}
|
||||
if let Some(v) = spacing {
|
||||
t.path_spacing = v;
|
||||
}
|
||||
refresh_bounds(t);
|
||||
}
|
||||
if n == 0 {
|
||||
return Err(bad(C, "select type on a path"));
|
||||
}
|
||||
Ok(n)
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "changed": n }))
|
||||
options(s)
|
||||
}
|
||||
|
||||
// ---------- placeholder / insert ----------
|
||||
|
||||
@@ -1399,6 +1399,8 @@ mod tests_panelcmds;
|
||||
#[cfg(test)]
|
||||
mod tests_pathops;
|
||||
#[cfg(test)]
|
||||
mod tests_pathtype;
|
||||
#[cfg(test)]
|
||||
mod tests_pattern;
|
||||
#[cfg(test)]
|
||||
mod tests_pdffidelity;
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Type on a path (#429): Type on a Path Options keep its brackets, flip it, align it to the path
|
||||
//! and space it; the selection tools drag its brackets to move it along the path and across it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, PathAlign, TextKind, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, Shape};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// 12 pt type on a path along `d` (SVG path data), starting where the path is nearest `at`.
|
||||
fn path_type(s: &mut Session, d: &str, at: [f64; 2], text: &str) -> NodeId {
|
||||
let pid = s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": text, "size": 12, "at": at})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Type on the 400 pt line from (100, 300) to (500, 300), from its middle on.
|
||||
fn on_line(s: &mut Session) -> NodeId {
|
||||
path_type(s, "M100 300 L500 300", [300.0, 310.0], "Slide")
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
k => panic!("not text: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The type's start and end brackets as stored.
|
||||
fn brackets(s: &Session, id: NodeId) -> (f64, Option<f64>) {
|
||||
match text(s, id).kind {
|
||||
TextKind::OnPath { start, end, .. } => (start, end),
|
||||
k => panic!("not type on a path: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyphs' ink bounds in the document.
|
||||
fn ink(s: &Session, id: NodeId) -> Rect {
|
||||
let t = text(s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
lay.glyphs
|
||||
.iter()
|
||||
.filter(|g| !g.outline.elements().is_empty())
|
||||
.map(|g| t.xf.transform_rect_bbox(g.outline.bounding_box()))
|
||||
.reduce(|a, b| a.union(b))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The glyph origins along the path, in reading order.
|
||||
fn origins(s: &Session, id: NodeId) -> Vec<Point> {
|
||||
let t = text(s, id);
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).glyphs.iter().map(|g| t.xf * g.origin).collect()
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn last_step(s: &Session) -> String {
|
||||
s.doc().unwrap().history.undo.last().unwrap().label.clone()
|
||||
}
|
||||
|
||||
/// Press, drag and release `tool` from `from` to `to`.
|
||||
fn drag(s: &mut Session, tool: &str, from: Point, to: Point, mods: Mods) {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
for (kind, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(mods), v).unwrap();
|
||||
}
|
||||
assert!(!s.in_interaction());
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_keep_the_brackets() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
assert!(near(brackets(&s, id).0, 0.5));
|
||||
// The query (what the dialog shows) has the current options.
|
||||
let mut q = s.execute("type.pathOptions", &json!({})).unwrap();
|
||||
assert!(near(q["start"].as_f64().unwrap(), 0.5), "{q}");
|
||||
q.as_object_mut().unwrap().remove("start");
|
||||
assert_eq!(q, json!({"end": null, "flip": false, "effect": "rainbow", "alignToPath": "baseline", "spacing": 0.0}));
|
||||
let start = brackets(&s, id).0;
|
||||
// OK with the dialog's values (effect, flip, align, spacing) leaves the type where it is.
|
||||
let before = origins(&s, id);
|
||||
s.execute("type.pathOptions", &json!({"effect": "rainbow", "flip": false, "alignToPath": "baseline", "spacing": 0})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (start, None));
|
||||
assert_eq!(origins(&s, id), before);
|
||||
// So does a new effect.
|
||||
s.execute("type.pathOptions", &json!({"effect": "skew"})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (start, None));
|
||||
assert_eq!(text(&s, id).path_effect, vectorcraft_doc::PathEffect::Skew);
|
||||
// The end bracket is set, and put back at the end of the path.
|
||||
s.execute("type.pathOptions", &json!({"end": 0.9})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (start, Some(0.9)));
|
||||
s.execute("type.pathOptions", &json!({"end": null})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (start, None));
|
||||
for bad in [json!({"start": "x"}), json!({"effect": "nope"}), json!({"alignToPath": "top"})] {
|
||||
assert!(s.execute("type.pathOptions", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_turns_the_type_to_the_other_side_of_its_stretch() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
let up = ink(&s, id);
|
||||
assert!(up.y1 < 301.0 && up.x0 > 299.0, "above the path from its middle: {up:?}");
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// The path runs the other way and the brackets swap ends: the type keeps the right half.
|
||||
assert!(near(r["start"].as_f64().unwrap(), 0.0) && near(r["end"].as_f64().unwrap(), 0.5), "{r}");
|
||||
let down = ink(&s, id);
|
||||
assert!(down.y0 > 299.0 && down.x0 > 299.0, "below the path, on the same half: {down:?}");
|
||||
let o = origins(&s, id);
|
||||
assert!(o.first().unwrap().x > o.last().unwrap().x, "reading right to left: {o:?}");
|
||||
// Flipped back, it is as it was.
|
||||
s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.5) && end.is_none(), "{start} {end:?}");
|
||||
assert!(ink(&s, id).y1 < 301.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flip_round_a_closed_path_keeps_the_type_in_place() {
|
||||
let mut s = session();
|
||||
let id = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [200.0, 90.0], "Ring");
|
||||
let (start, _) = brackets(&s, id);
|
||||
assert!(near(start, 0.125), "{start}");
|
||||
let first = origins(&s, id)[0];
|
||||
s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
|
||||
// Once round from the same point, the other way.
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.875) && end.is_none(), "{start} {end:?}");
|
||||
assert!(origins(&s, id)[0].distance(first) < 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_to_path_and_spacing() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
s.execute("type.pathOptions", &json!({"alignToPath": "ascender"})).unwrap();
|
||||
assert_eq!(text(&s, id).path_align, PathAlign::Ascender);
|
||||
let hang = ink(&s, id);
|
||||
assert!(hang.y0 > 299.0, "hangs below the path: {hang:?}");
|
||||
s.execute("type.pathOptions", &json!({"alignToPath": "center"})).unwrap();
|
||||
let mid = ink(&s, id);
|
||||
assert!(mid.y0 < 300.0 && mid.y1 > 300.0, "the path runs through it: {mid:?}");
|
||||
// Spacing closes glyphs up round the outside of a curve.
|
||||
let id = path_type(&mut s, "M100 400 A60 60 0 0 1 220 400", [100.0, 400.0], "Round the bend");
|
||||
let spread = |s: &Session| {
|
||||
let o = origins(s, id);
|
||||
o.first().unwrap().distance(*o.last().unwrap())
|
||||
};
|
||||
let loose = spread(&s);
|
||||
s.execute("type.pathOptions", &json!({"spacing": 6})).unwrap();
|
||||
assert!(spread(&s) < loose - 1.0, "{} < {loose}", spread(&s));
|
||||
assert_eq!(s.execute("type.pathOptions", &json!({})).unwrap()["spacing"], json!(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_centre_bracket_slides_the_type_and_across_the_path_flips_it() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
let n = undo_len(&s);
|
||||
// The centre bracket stands midway between the start (300) and the end of the path (500).
|
||||
drag(&mut s, "selection", Point::new(400.0, 295.0), Point::new(440.0, 295.0), Mods::default());
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(last_step(&s), "Move Type on a Path");
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.6) && end.is_none(), "{start} {end:?}");
|
||||
assert!(ink(&s, id).x0 > 339.0);
|
||||
// Direct Selection drags it too; Cmd/Ctrl held below the path only slides it.
|
||||
drag(&mut s, "directSelection", Point::new(420.0, 295.0), Point::new(380.0, 330.0), Mods { cmd: true, ..Mods::default() });
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.5) && end.is_none(), "{start} {end:?}");
|
||||
assert!(ink(&s, id).y1 < 301.0, "still above the path");
|
||||
// Across the path: flipped, on the same stretch.
|
||||
drag(&mut s, "selection", Point::new(400.0, 295.0), Point::new(400.0, 330.0), Mods::default());
|
||||
assert_eq!(undo_len(&s), n + 3);
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.0) && end.is_some_and(|e| near(e, 0.5)), "{start} {end:?}");
|
||||
assert!(ink(&s, id).y0 > 299.0, "below the path");
|
||||
// The step undoes as one.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.5) && end.is_none(), "{start} {end:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_start_and_end_brackets_sets_where_the_type_runs() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
// The start bracket from the middle (300) to 200.
|
||||
drag(&mut s, "selection", Point::new(300.0, 290.0), Point::new(200.0, 290.0), Mods::default());
|
||||
assert!(near(brackets(&s, id).0, 0.25));
|
||||
// The end bracket from the end of the path to 400.
|
||||
drag(&mut s, "directSelection", Point::new(500.0, 290.0), Point::new(400.0, 290.0), Mods::default());
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.25) && end.is_some_and(|e| near(e, 0.75)), "{start} {end:?}");
|
||||
let v: Value = s.execute("type.pathOptions", &json!({})).unwrap();
|
||||
assert!(near(v["end"].as_f64().unwrap(), 0.75));
|
||||
let r = ink(&s, id);
|
||||
assert!(r.x0 > 199.0 && r.x1 < 401.0, "between the brackets: {r:?}");
|
||||
}
|
||||
@@ -73,7 +73,7 @@ fn stroke_unit_follows_the_preference() {
|
||||
assert_eq!(s.stroke_unit(), Unit::Points);
|
||||
s.prefs.units_stroke = "millimeters".into();
|
||||
assert_eq!(s.stroke_unit(), Unit::Millimeters);
|
||||
assert_eq!(s.stroke_unit().format(1.0), "0.353 mm");
|
||||
assert_eq!(s.stroke_unit().format(1.0), "0.3528 mm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Arc-length parametrization of a path: the point and direction at a distance along it, and the
|
||||
//! distance along it of the point nearest another (type on a path lays its glyphs out and the
|
||||
//! selection tools drag its brackets by distance along the path).
|
||||
|
||||
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest, PathSeg, Point, Vec2};
|
||||
|
||||
use crate::PathData;
|
||||
|
||||
const ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// A path's segments with their lengths, measured once.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ArcPath {
|
||||
/// Segments of some length, each with the distance to its start and its length.
|
||||
segs: Vec<(PathSeg, f64, f64)>,
|
||||
len: f64,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl ArcPath {
|
||||
pub fn new(path: &PathData) -> Self {
|
||||
let mut segs = Vec::new();
|
||||
let mut cum = 0.0;
|
||||
for s in path.to_bezpath().segments() {
|
||||
let l = s.arclen(ACCURACY);
|
||||
if l.is_finite() && l > 1e-9 {
|
||||
segs.push((s, cum, l));
|
||||
cum += l;
|
||||
}
|
||||
}
|
||||
Self { segs, len: cum, closed: path.is_closed() }
|
||||
}
|
||||
|
||||
/// The path's length.
|
||||
pub fn len(&self) -> f64 {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// No segment has any length.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.segs.is_empty()
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.closed
|
||||
}
|
||||
|
||||
/// Point and unit tangent at distance `s` along the path: round and round a closed path, held
|
||||
/// at an open one's ends.
|
||||
pub fn at(&self, s: f64) -> (Point, Vec2) {
|
||||
let s = if self.closed && self.len > 0.0 { s.rem_euclid(self.len) } else { s.clamp(0.0, self.len) };
|
||||
let i = self.segs.partition_point(|(_, c, _)| *c <= s).saturating_sub(1);
|
||||
let Some(&(seg, c, l)) = self.segs.get(i) else { return (Point::ZERO, Vec2::new(1.0, 0.0)) };
|
||||
let t = seg.inv_arclen((s - c).min(l), ACCURACY).clamp(0.0, 1.0);
|
||||
let p = seg.eval(t);
|
||||
let (t0, t1) = ((t - 1e-4).max(0.0), (t + 1e-4).min(1.0));
|
||||
let d = seg.eval(t1) - seg.eval(t0);
|
||||
let len = d.hypot();
|
||||
(p, if len > 1e-12 { d / len } else { Vec2::new(1.0, 0.0) })
|
||||
}
|
||||
|
||||
/// The distance along the path of its point nearest `p`.
|
||||
pub fn nearest(&self, p: Point) -> Option<f64> {
|
||||
self.segs
|
||||
.iter()
|
||||
.map(|(seg, c, l)| {
|
||||
let n = seg.nearest(p, ACCURACY);
|
||||
(n.distance_sq, c + seg.subsegment(0.0..n.t).arclen(ACCURACY).min(*l))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, s)| s)
|
||||
}
|
||||
|
||||
/// [`Self::nearest`] as a fraction (0..1) of the path's length.
|
||||
pub fn fraction_at(&self, p: Point) -> Option<f64> {
|
||||
(self.len > 0.0).then(|| self.nearest(p)).flatten().map(|s| (s / self.len).clamp(0.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::shapes;
|
||||
use kurbo::{Rect, Shape};
|
||||
|
||||
#[test]
|
||||
fn points_and_nearest_distances_along_a_path() {
|
||||
let line = PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1));
|
||||
let a = ArcPath::new(&line);
|
||||
assert!((a.len() - 100.0).abs() < 1e-6 && !a.is_closed());
|
||||
assert!(a.at(25.0).0.distance(Point::new(25.0, 0.0)) < 1e-6);
|
||||
// Held at an open path's ends.
|
||||
assert!(a.at(150.0).0.distance(Point::new(100.0, 0.0)) < 1e-6);
|
||||
assert!((a.nearest(Point::new(40.0, 30.0)).unwrap() - 40.0).abs() < 1e-6);
|
||||
// Round a closed one.
|
||||
let sq = ArcPath::new(&shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||||
assert!(sq.is_closed() && (sq.len() - 40.0).abs() < 1e-6);
|
||||
assert!(sq.at(45.0).0.distance(sq.at(5.0).0) < 1e-6);
|
||||
assert!(ArcPath::new(&PathData::default()).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//! Coordinates are document points (1/72 in), y pointing down.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod arc;
|
||||
pub mod bez;
|
||||
pub mod hit;
|
||||
pub mod path;
|
||||
@@ -15,6 +16,7 @@ pub mod recognize;
|
||||
pub mod shapes;
|
||||
pub mod snap;
|
||||
|
||||
pub use arc::ArcPath;
|
||||
pub use kurbo;
|
||||
pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2};
|
||||
pub use path::{Anchor, AnchorKind, FillRule, PathData, SubPath};
|
||||
|
||||
@@ -269,7 +269,7 @@ impl TextLine {
|
||||
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle);
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
if let Some(path) = path {
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0 };
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds);
|
||||
return Some((t, self.opacity));
|
||||
|
||||
@@ -96,7 +96,7 @@ fn real_text_lands_within_half_a_point_of_the_outlines() {
|
||||
TextRun { text: " ok".into(), style: CharStyle { rotation: 20.0, ..style(20.0) } },
|
||||
];
|
||||
let mut on_path = TextObject::point(Point::ZERO, "along the arc", style(18.0));
|
||||
on_path.kind = TextKind::OnPath { path: arc(), start: 0.0 };
|
||||
on_path.kind = TextKind::OnPath { path: arc(), start: 0.0, end: None };
|
||||
on_path.xf = Affine::IDENTITY;
|
||||
for t in [plain, turned, mixed, on_path] {
|
||||
let what = t.plain_text();
|
||||
|
||||
@@ -1665,7 +1665,13 @@ impl Writer<'_> {
|
||||
self.warn("bidirectional text is outlined to preserve shaping and visual order");
|
||||
return self.text_outlines(n, t);
|
||||
}
|
||||
if let TextKind::OnPath { path, start } = &t.kind {
|
||||
if let TextKind::OnPath { path, start, end } = &t.kind {
|
||||
// `<textPath>` has a start offset only: an end bracket, Align to Path and Spacing
|
||||
// keep their look as outlines.
|
||||
if end.is_some() || t.path_align != vectorcraft_doc::PathAlign::Baseline || t.path_spacing != 0.0 {
|
||||
self.warn("type on a path with an end bracket, Align to Path or Spacing is outlined to keep its look");
|
||||
return self.text_outlines(n, t);
|
||||
}
|
||||
return self.text_on_path(n, t, path, *start);
|
||||
}
|
||||
self.note_fonts(t, &lay);
|
||||
|
||||
@@ -586,7 +586,7 @@ impl Ctx {
|
||||
let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0];
|
||||
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
|
||||
let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0));
|
||||
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start };
|
||||
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start, end: None };
|
||||
obj.xf = Affine::IDENTITY;
|
||||
} else if vertical {
|
||||
// Type on a vertical path: dy is extra advance, dx shifts across the column.
|
||||
@@ -606,7 +606,7 @@ impl Ctx {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((x0, y0));
|
||||
bp.line_to((x0, y0 + width + 1.0));
|
||||
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start: 0.0 };
|
||||
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start: 0.0, end: None };
|
||||
obj.xf = Affine::IDENTITY;
|
||||
} else {
|
||||
let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]);
|
||||
|
||||
@@ -150,7 +150,7 @@ fn text_path_becomes_type_on_a_path() {
|
||||
);
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 3);
|
||||
let TextKind::OnPath { path, start } = &t[0].kind else { panic!("{:?}", t[0].kind) };
|
||||
let TextKind::OnPath { path, start, .. } = &t[0].kind else { panic!("{:?}", t[0].kind) };
|
||||
assert!(close(*start, 0.25, 1e-9));
|
||||
let b = t[0].xf.transform_rect_bbox(path.bounds().unwrap());
|
||||
assert!(close(b.x0, 10.0, 1e-6) && close(b.x1, 210.0, 1e-6) && close(b.y0, 100.0, 1e-6), "{b:?}");
|
||||
@@ -310,7 +310,7 @@ fn pattern_fill_on_text_and_shapes() {
|
||||
fn vertical_writing_mode_runs_down_a_path() {
|
||||
let d = open(r#"<text x="100" y="20" writing-mode="tb" font-size="10">Down</text>"#);
|
||||
let t = only_text(&d);
|
||||
let TextKind::OnPath { path, start } = &t.kind else { panic!("{:?}", t.kind) };
|
||||
let TextKind::OnPath { path, start, .. } = &t.kind else { panic!("{:?}", t.kind) };
|
||||
assert_eq!(*start, 0.0);
|
||||
let b = path.bounds().unwrap();
|
||||
assert!(close(b.x0, b.x1, 1e-9) && close(b.y0, 20.0, 1e-9) && b.height() > 20.0, "{b:?}");
|
||||
|
||||
@@ -420,19 +420,42 @@ fn text_on_path_roundtrip() {
|
||||
curve.move_to((20.0, 150.0));
|
||||
curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0));
|
||||
let mut t = TextObject::point(Point::new(0.0, 0.0), "On a curve", CharStyle { size: 14.0, ..CharStyle::default() });
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.25 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.25, end: None };
|
||||
let id = d.alloc_id();
|
||||
let d = doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]);
|
||||
let r = roundtrip(&d);
|
||||
let a = art(&r);
|
||||
let NodeKind::Text(t) = &a[0].kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "On a curve");
|
||||
let TextKind::OnPath { path, start } = &t.kind else { panic!("not type on a path: {:?}", t.kind) };
|
||||
let TextKind::OnPath { path, start, .. } = &t.kind else { panic!("not type on a path: {:?}", t.kind) };
|
||||
assert!((start - 0.25).abs() < 1e-6, "start {start}");
|
||||
let doc_path = path.bounds().map(|b| t.xf.transform_rect_bbox(b)).unwrap();
|
||||
assert!(close_rect(doc_path, PathData::from_bezpath(&curve).bounds().unwrap(), 0.01), "{doc_path:?}");
|
||||
}
|
||||
|
||||
/// An end bracket, Align to Path or Spacing, which `<textPath>` can't express, keep their look as
|
||||
/// outlines (#429).
|
||||
#[test]
|
||||
fn text_on_path_options_svg_cannot_express_are_outlined() {
|
||||
let mut curve = vectorcraft_geom::BezPath::new();
|
||||
curve.move_to((20.0, 150.0));
|
||||
curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0));
|
||||
for set in [0, 1, 2] {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let mut t = TextObject::point(Point::ZERO, "On a curve", CharStyle { size: 14.0, ..CharStyle::default() });
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: (set == 0).then_some(0.8) };
|
||||
if set == 1 {
|
||||
t.path_align = vectorcraft_doc::PathAlign::Center;
|
||||
}
|
||||
if set == 2 {
|
||||
t.path_spacing = 4.0;
|
||||
}
|
||||
let id = d.alloc_id();
|
||||
let svg = export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &ExportOptions::default());
|
||||
assert!(!svg.contains("<textPath"), "{set}: {svg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styling_modes_roundtrip() {
|
||||
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::StyleEntities, Styling::InternalCss] {
|
||||
|
||||
+60
-61
@@ -2,9 +2,10 @@
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2};
|
||||
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2};
|
||||
use unicode_bidi::{BidiInfo, Level};
|
||||
use vectorcraft_doc::{CharStyle, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathEffect, TextKind, TextObject};
|
||||
use vectorcraft_doc::{CharStyle, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject};
|
||||
use vectorcraft_geom::{ArcPath, PathData};
|
||||
|
||||
use crate::composer::{Breakpoint, compose};
|
||||
use crate::fontdb::FontDb;
|
||||
@@ -184,7 +185,7 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
|
||||
cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect();
|
||||
flow(&mut cx, ¶s, &t.para, Some(®ions));
|
||||
}
|
||||
TextKind::OnPath { path, start } => on_path(&mut cx, ¶s, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect),
|
||||
TextKind::OnPath { path, .. } => on_path(&mut cx, ¶s, t, path),
|
||||
}
|
||||
if vertical && !is_on_path {
|
||||
// Glyph origins, outlines and transforms to text space (lines stay in line space).
|
||||
@@ -1168,43 +1169,31 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
}
|
||||
}
|
||||
|
||||
/// Arc-length parameterised path.
|
||||
struct ArcPath {
|
||||
segs: Vec<(PathSeg, f64, f64)>,
|
||||
len: f64,
|
||||
}
|
||||
|
||||
impl ArcPath {
|
||||
fn new(p: &BezPath) -> Self {
|
||||
let mut segs = Vec::new();
|
||||
let mut cum = 0.0;
|
||||
for s in p.segments() {
|
||||
let l = s.arclen(1e-4);
|
||||
if l > 1e-9 {
|
||||
segs.push((s, cum, l));
|
||||
cum += l;
|
||||
/// The distance along type on a path each glyph takes: its advance measured `spacing` points above
|
||||
/// the path (Type on a Path Options › Spacing), so glyphs close up round the outside of a curve and
|
||||
/// open up round the inside; just the advances without spacing. Glyphs start `from` along the path.
|
||||
fn path_steps(ap: &ArcPath, glyphs: &[SGlyph], from: f64, spacing: f64) -> Vec<f64> {
|
||||
let mut s = from;
|
||||
glyphs
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let mut step = g.adv;
|
||||
if spacing != 0.0 && g.adv > 1e-9 {
|
||||
// How far the path turns across the glyph: positive bending away from its top.
|
||||
let ((_, a), (_, b)) = (ap.at(s), ap.at(s + g.adv));
|
||||
let curvature = a.cross(b).atan2(a.dot(b)) / g.adv;
|
||||
step = g.adv / (1.0 + curvature * spacing).clamp(0.25, 4.0);
|
||||
}
|
||||
}
|
||||
Self { segs, len: cum }
|
||||
}
|
||||
|
||||
/// Point and unit tangent at arc length `s`.
|
||||
fn at(&self, s: f64) -> (Point, Vec2) {
|
||||
let s = s.clamp(0.0, self.len);
|
||||
let i = self.segs.partition_point(|(_, c, _)| *c <= s).saturating_sub(1);
|
||||
let (seg, c, l) = self.segs[i];
|
||||
let t = seg.inv_arclen((s - c).min(l), 1e-4).clamp(0.0, 1.0);
|
||||
let p = seg.eval(t);
|
||||
let (t0, t1) = ((t - 1e-4).max(0.0), (t + 1e-4).min(1.0));
|
||||
let d = seg.eval(t1) - seg.eval(t0);
|
||||
let len = d.hypot();
|
||||
(p, if len > 1e-12 { d / len } else { Vec2::new(1.0, 0.0) })
|
||||
}
|
||||
s += step;
|
||||
step
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &BezPath, start: f64, closed: bool, effect: PathEffect) {
|
||||
/// Lay type on a path out along `path` (text space), between its brackets.
|
||||
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &PathData) {
|
||||
cx.out.on_path = true;
|
||||
let para = &t.para;
|
||||
let mut sg = Vec::new();
|
||||
// The first paragraph's direction aligns the line (Auto) and sets the caret's.
|
||||
let mut rtl = None;
|
||||
@@ -1226,7 +1215,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
(s.0, s.1)
|
||||
});
|
||||
let text_len = cx.text.len();
|
||||
if ap.segs.is_empty() {
|
||||
if ap.is_empty() {
|
||||
cx.out.overflow = !sg.is_empty();
|
||||
cx.out.lines.push(LineInfo {
|
||||
rtl,
|
||||
@@ -1243,32 +1232,43 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
});
|
||||
return;
|
||||
}
|
||||
let centre = path.bounding_box().center();
|
||||
let s_start = start.clamp(0.0, 1.0) * ap.len;
|
||||
let avail = if closed { ap.len } else { ap.len - s_start };
|
||||
let w: f64 = sg.iter().map(|g| g.adv).sum();
|
||||
let centre = path.bounds().unwrap_or_default().center();
|
||||
let (from, to) = t.kind.path_span().unwrap_or((0.0, 1.0));
|
||||
let s_start = from * ap.len();
|
||||
let avail = (to - from) * ap.len();
|
||||
let spacing = if t.path_spacing.is_finite() { t.path_spacing } else { 0.0 };
|
||||
let mut steps = path_steps(&ap, &sg, s_start, spacing);
|
||||
let w: f64 = steps.iter().sum();
|
||||
let s0 = match para.justify {
|
||||
Justify::Auto if rtl => s_start + (avail - w).max(0.0),
|
||||
Justify::Center | Justify::JustifyCenter => s_start + ((avail - w) * 0.5).max(0.0),
|
||||
Justify::Right | Justify::JustifyRight => s_start + (avail - w).max(0.0),
|
||||
_ => s_start,
|
||||
};
|
||||
if spacing != 0.0 && s0 != s_start {
|
||||
// Spaced from where the alignment puts them, the curve under them is another.
|
||||
steps = path_steps(&ap, &sg, s0, spacing);
|
||||
}
|
||||
// Align to Path: how far down (glyph space) the type moves to run its ascender, centre or
|
||||
// descender along the path instead of its baseline.
|
||||
let rise = match t.path_align {
|
||||
PathAlign::Baseline => 0.0,
|
||||
PathAlign::Ascender => m.0,
|
||||
PathAlign::Descender => -m.1,
|
||||
PathAlign::Center => (m.0 - m.1) * 0.5,
|
||||
};
|
||||
let mut x = 0.0;
|
||||
for g in &sg {
|
||||
for (g, &step) in sg.iter().zip(&steps) {
|
||||
let s = s0 + x;
|
||||
if s + g.adv - s_start > avail + 1e-6 {
|
||||
if s + step - s_start > avail + 1e-6 {
|
||||
cx.out.overflow = true;
|
||||
break;
|
||||
}
|
||||
let mut mid = s + g.adv * 0.5;
|
||||
if closed {
|
||||
mid = mid.rem_euclid(ap.len);
|
||||
}
|
||||
let (p, dir) = ap.at(mid);
|
||||
let (p, dir) = ap.at(s + step * 0.5);
|
||||
let angle = dir.y.atan2(dir.x);
|
||||
let half = Affine::translate((-g.adv * 0.5, 0.0));
|
||||
let half = Affine::translate((-g.adv * 0.5, rise));
|
||||
// Glyph space: x along the advance, y down from the baseline; `pre` maps it onto the path.
|
||||
let pre = match effect {
|
||||
let pre = match t.path_effect {
|
||||
PathEffect::Rainbow => Affine::translate(p.to_vec2()) * Affine::rotate(angle) * half,
|
||||
// x axis along the tangent, y axis stays vertical.
|
||||
PathEffect::Skew => Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, 0.0, 1.0, 0.0, 0.0]) * half,
|
||||
@@ -1277,10 +1277,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
let sx = if dir.x < 0.0 { -1.0 } else { 1.0 };
|
||||
Affine::translate(p.to_vec2()) * Affine::new([sx, 0.0, -dir.y * sx, dir.x * sx, 0.0, 0.0]) * half
|
||||
}
|
||||
PathEffect::StairStep => {
|
||||
let s_left = if closed { s.rem_euclid(ap.len) } else { s };
|
||||
Affine::translate(ap.at(s_left).0.to_vec2())
|
||||
}
|
||||
PathEffect::StairStep => Affine::translate(ap.at(s).0.to_vec2()) * Affine::translate((0.0, rise)),
|
||||
// x axis along the tangent; vertical edges point at the path's centre (kept on the glyph's
|
||||
// up side, and never closer than ~17° to the baseline so glyphs stay legible).
|
||||
PathEffect::Gravity => {
|
||||
@@ -1291,17 +1288,19 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
up = -up;
|
||||
}
|
||||
if up.dot(n) < 0.3 {
|
||||
let t = up - n * up.dot(n);
|
||||
up = n * 0.3 + t / t.hypot().max(1e-9) * (1.0 - 0.09f64).sqrt();
|
||||
let along = up - n * up.dot(n);
|
||||
up = n * 0.3 + along / along.hypot().max(1e-9) * (1.0 - 0.09f64).sqrt();
|
||||
}
|
||||
Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, -up.x, -up.y, 0.0, 0.0]) * half
|
||||
}
|
||||
};
|
||||
cx.emit(g, pre, p - dir * (g.adv * 0.5), angle, g.adv, 0);
|
||||
x += g.adv;
|
||||
// The caret's baseline moves with the type.
|
||||
let origin = p - dir * (g.adv * 0.5) + Vec2::new(-dir.y, dir.x) * rise;
|
||||
cx.emit(g, pre, origin, angle, g.adv, 0);
|
||||
x += step;
|
||||
}
|
||||
let (ps, _) = ap.at(if closed { s0.rem_euclid(ap.len) } else { s0 });
|
||||
let (pe, _) = ap.at(if closed { (s0 + x).rem_euclid(ap.len) } else { s0 + x });
|
||||
let (ps, _) = ap.at(s0);
|
||||
let (pe, _) = ap.at(s0 + x);
|
||||
cx.out.lines.push(LineInfo {
|
||||
rtl,
|
||||
baseline: ps.y,
|
||||
@@ -1313,7 +1312,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
end: text_len,
|
||||
glyph_start: 0,
|
||||
glyph_end: cx.out.glyphs.len(),
|
||||
avail: (0.0, ap.len),
|
||||
avail: (0.0, ap.len()),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -328,24 +328,6 @@ pub fn caret_vertical(layout: &TextLayout, byte: usize, delta: i32, goal_x: f64)
|
||||
byte_in_line(layout, li as usize, goal_x)
|
||||
}
|
||||
|
||||
/// Nearest point on `path` to `p`: (fraction of the path's arc length 0..1, distance). Used to
|
||||
/// start type on a path where the user clicked.
|
||||
pub fn path_fraction_at(path: &BezPath, p: Point) -> (f64, f64) {
|
||||
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
let mut total = 0.0;
|
||||
let mut best = (0.0, f64::INFINITY);
|
||||
for seg in path.segments() {
|
||||
let len = seg.arclen(1e-3);
|
||||
let n = seg.nearest(p, 1e-4);
|
||||
let d = n.distance_sq.sqrt();
|
||||
if d < best.1 {
|
||||
best = (total + seg.subsegment(0.0..n.t).arclen(1e-3), d);
|
||||
}
|
||||
total += len;
|
||||
}
|
||||
if total <= 0.0 { (0.0, best.1) } else { ((best.0 / total).clamp(0.0, 1.0), best.1) }
|
||||
}
|
||||
|
||||
/// Highlight quads (text space, clockwise from top-left) covering the selected bytes `a..b`.
|
||||
pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4]> {
|
||||
let (a, b) = (a.min(b), a.max(b));
|
||||
|
||||
@@ -271,7 +271,7 @@ fn on_path_placement() {
|
||||
line.move_to((0.0, 50.0));
|
||||
line.line_to((500.0, 50.0));
|
||||
let mut t = point("Path", style(20.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.1 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.1, end: None };
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.on_path && !l.overflow);
|
||||
assert_eq!(l.glyphs.len(), 4);
|
||||
@@ -283,7 +283,7 @@ fn on_path_placement() {
|
||||
let mut v = BezPath::new();
|
||||
v.move_to((0.0, 0.0));
|
||||
v.line_to((0.0, 300.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&v), start: 0.0 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&v), start: 0.0, end: None };
|
||||
let l = layout(db(), &t);
|
||||
for g in &l.glyphs {
|
||||
assert!((g.angle - std::f64::consts::FRAC_PI_2).abs() < 1e-3);
|
||||
@@ -302,7 +302,7 @@ fn on_path_effects_orient_glyphs() {
|
||||
diag.move_to((0.0, 0.0));
|
||||
diag.line_to((400.0, 400.0));
|
||||
let mut t = point("H", style(40.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&diag), start: 0.1 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&diag), start: 0.1, end: None };
|
||||
let bbox = |t: &TextObject| layout(db(), t).glyphs[0].outline.bounding_box();
|
||||
let rainbow = bbox(&t);
|
||||
t.path_effect = PathEffect::StairStep;
|
||||
@@ -315,7 +315,7 @@ fn on_path_effects_orient_glyphs() {
|
||||
assert!(skew.height() > stair.height() && skew.width() < rainbow.width() + 1e-6, "{skew:?}");
|
||||
// Gravity on a circle: glyphs point away from the centre (same as Rainbow on a circle).
|
||||
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0, end: None };
|
||||
t.path_effect = PathEffect::Rainbow;
|
||||
let a = layout(db(), &t).glyphs[0].outline.bounding_box();
|
||||
t.path_effect = PathEffect::Gravity;
|
||||
@@ -329,7 +329,7 @@ fn on_path_effects_orient_glyphs() {
|
||||
fn on_path_circle_and_overflow() {
|
||||
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);
|
||||
let mut t = point("Around the circle", style(14.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0, end: None };
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow);
|
||||
for g in &l.glyphs {
|
||||
@@ -339,7 +339,7 @@ fn on_path_circle_and_overflow() {
|
||||
let mut short = BezPath::new();
|
||||
short.move_to((0.0, 0.0));
|
||||
short.line_to((30.0, 0.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&short), start: 0.0 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&short), start: 0.0, end: None };
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.overflow && l.glyphs.len() < t.plain_text().len());
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ fn area_type_starts_at_the_right_edge_and_wraps_to_the_left() {
|
||||
#[test]
|
||||
fn type_on_a_path_stays_horizontal() {
|
||||
let mut t = vertical("ab");
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0 };
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0, end: None };
|
||||
assert!(!layout(FontDb::global(), &t).vertical);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes
|
||||
//! and mesh envelopes and their handles ([`MeshEdit`]). Dragging a corner or an edge of area
|
||||
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size.
|
||||
//! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]).
|
||||
//!
|
||||
//! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors
|
||||
//! (the fill, or Alt, selects the whole path); dragging the segment bends it if it's curved, else
|
||||
@@ -31,6 +32,7 @@ use crate::bbox::move_delta;
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::{PointSnap, Targets};
|
||||
use crate::meshedit::MeshEdit;
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
use crate::select::{MoveSnap, is_area_type, matrix_json};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
@@ -70,6 +72,8 @@ enum State {
|
||||
add: bool,
|
||||
},
|
||||
Corner(CornerDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
Bracket(BracketDrag),
|
||||
/// Dragging a point of a blend's spine from `from`.
|
||||
SpinePoint {
|
||||
id: NodeId,
|
||||
@@ -310,6 +314,10 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(b) = BracketDrag::hit(cx, ev) {
|
||||
self.state = State::Bracket(b);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id))
|
||||
&& let Some(out) = hit_spine_handle(cx, sel, p, tol)
|
||||
{
|
||||
@@ -502,6 +510,15 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::Idle;
|
||||
c.finish()
|
||||
}
|
||||
(PointerKind::Drag, State::Bracket(mut b)) => {
|
||||
let out = b.drag(cx, p, ev.mods.cmd);
|
||||
self.state = State::Bracket(b);
|
||||
out
|
||||
}
|
||||
(PointerKind::Up, State::Bracket(b)) => {
|
||||
self.state = State::Idle;
|
||||
b.finish()
|
||||
}
|
||||
(
|
||||
PointerKind::Up,
|
||||
State::MoveAnchors { began, .. }
|
||||
@@ -552,6 +569,7 @@ impl Tool for DirectSelectionTool {
|
||||
out.extend(self.mesh.overlays(cx));
|
||||
if !self.group {
|
||||
out.extend(frame_overlays(cx));
|
||||
out.extend(pathtype::overlays(cx));
|
||||
}
|
||||
if matches!(self.state, State::Handle { .. } | State::MoveAnchors { .. } | State::MoveObject { .. }) {
|
||||
out.extend(self.guides.iter().cloned());
|
||||
@@ -568,6 +586,9 @@ impl Tool for DirectSelectionTool {
|
||||
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p)) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
if !self.group && (matches!(self.state, State::Bracket(_)) || over_bracket(cx, p)) {
|
||||
return Cursor::PathBracket;
|
||||
}
|
||||
// A press on the highlighted anchor picks it, not the guide.
|
||||
self.guide.cursor(cx, p).filter(|_| self.hover.is_none() || self.guide.busy()).unwrap_or(Cursor::ArrowHollow)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod guides;
|
||||
pub mod meshblend;
|
||||
pub mod meshedit;
|
||||
pub mod params;
|
||||
pub mod pathtype;
|
||||
pub mod pen;
|
||||
pub mod place;
|
||||
pub mod printtiling;
|
||||
@@ -409,6 +410,8 @@ pub enum Cursor {
|
||||
BlendObject,
|
||||
/// The Blend tool over an anchor point (the blend starts there): a crosshair with a target.
|
||||
BlendAnchor,
|
||||
/// Over a bracket of selected type on a path (a drag moves it): the arrow with a bracket.
|
||||
PathBracket,
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
@@ -569,6 +572,21 @@ pub(crate) mod testutil {
|
||||
(d, id)
|
||||
}
|
||||
|
||||
/// [`doc_with_rect`] plus type on a 300 pt path from (100, 300) to the right, from 20 % on.
|
||||
pub fn doc_with_path_type() -> (Document, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut t = vectorcraft_doc::TextObject::point(Point::ZERO, "Path type", Default::default());
|
||||
let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![
|
||||
vectorcraft_geom::PathEl::MoveTo(Point::new(100.0, 300.0)),
|
||||
vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)),
|
||||
]));
|
||||
t.kind = vectorcraft_doc::TextKind::OnPath { path, start: 0.2, end: None };
|
||||
d.insert(Some(l), 1, Node::new(id, vectorcraft_doc::NodeKind::Text(Box::new(t)))).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
pub fn paint() -> PaintDefaults {
|
||||
PaintDefaults::default()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Type on a path's brackets: selected type on a path shows a start, a centre and an end bracket
|
||||
//! with the Selection and Direct Selection tools. Dragging the start or end bracket sets where the
|
||||
//! type begins or ends along its path; dragging the centre bracket slides the type along the path
|
||||
//! (its brackets with it) and, dragged across the path, flips the type to the other side
|
||||
//! (Cmd/Ctrl held: it only slides). Each drag is one undo step of `type.pathOptions`. The tools
|
||||
//! draw the brackets from the same geometry they hit-test.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, TextKind};
|
||||
use vectorcraft_geom::kurbo::{Line, ParamCurveNearest};
|
||||
use vectorcraft_geom::{Affine, ArcPath, Point, Vec2};
|
||||
|
||||
use crate::{Action, Overlay, PointerEvent, ToolContext};
|
||||
|
||||
/// How far a bracket reaches (screen px): up from the path on the type's side, down across it,
|
||||
/// and the foot of the start and end brackets along the path, into the type's span.
|
||||
const UP_PX: f64 = 14.0;
|
||||
const DOWN_PX: f64 = 4.0;
|
||||
const FOOT_PX: f64 = 4.0;
|
||||
/// How far past the path (screen px) the centre bracket goes before the type flips.
|
||||
const FLIP_PX: f64 = 6.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Bracket {
|
||||
Start,
|
||||
Centre,
|
||||
End,
|
||||
}
|
||||
|
||||
impl Bracket {
|
||||
/// Hit-test order: the centre bracket first, then the start one (a closed path's span with
|
||||
/// no end of its own has both ends at the start).
|
||||
const ALL: [Bracket; 3] = [Bracket::Centre, Bracket::Start, Bracket::End];
|
||||
}
|
||||
|
||||
/// The brackets of a selected type on a path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Brackets {
|
||||
pub id: NodeId,
|
||||
/// Text space → document.
|
||||
xf: Affine,
|
||||
/// The path in text space.
|
||||
arc: ArcPath,
|
||||
/// [`TextKind::path_span`].
|
||||
span: (f64, f64),
|
||||
/// The end bracket was placed (not the end of the path, or once round it).
|
||||
end_set: bool,
|
||||
}
|
||||
|
||||
impl Brackets {
|
||||
/// The brackets of the selected type on a path the tools can edit (outside perspective).
|
||||
pub fn of(cx: &ToolContext) -> Vec<Self> {
|
||||
cx.selection.objects.iter().filter_map(|id| Self::of_node(cx, *id)).collect()
|
||||
}
|
||||
|
||||
fn of_node(cx: &ToolContext, id: NodeId) -> Option<Self> {
|
||||
let n = cx.doc.node(id).filter(|n| n.perspective.is_none() && cx.doc.is_editable(id))?;
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let TextKind::OnPath { path, end, .. } = &t.kind else { return None };
|
||||
let det = t.xf.determinant();
|
||||
let arc = ArcPath::new(path);
|
||||
if !det.is_finite() || det.abs() < 1e-12 || arc.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self { id, xf: t.xf, arc, span: t.kind.path_span()?, end_set: end.is_some() })
|
||||
}
|
||||
|
||||
/// Where bracket `b` stands, as a fraction of the path's length (past 1 round a closed path).
|
||||
fn at(&self, b: Bracket) -> f64 {
|
||||
match b {
|
||||
Bracket::Start => self.span.0,
|
||||
Bracket::Centre => (self.span.0 + self.span.1) * 0.5,
|
||||
Bracket::End => self.span.1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The point at fraction `f` of the path, the path's direction there and the side the type
|
||||
/// stands on, in document space.
|
||||
fn frame(&self, f: f64) -> (Point, Vec2, Vec2) {
|
||||
let (p, dir) = self.arc.at(f * self.arc.len());
|
||||
let unit = |v: Vec2| {
|
||||
let w = self.xf * v.to_point() - self.xf * Point::ZERO;
|
||||
w / w.hypot().max(1e-12)
|
||||
};
|
||||
// Glyphs stand on the left of the path's direction (y down): their top faces (dir.y, -dir.x).
|
||||
(self.xf * p, unit(dir), unit(Vec2::new(dir.y, -dir.x)))
|
||||
}
|
||||
|
||||
/// The lines that draw bracket `b` at `zoom` (screen px per point).
|
||||
fn lines(&self, b: Bracket, zoom: f64) -> Vec<(Point, Point)> {
|
||||
let px = 1.0 / zoom.max(1e-9);
|
||||
let (p, dir, up) = self.frame(self.at(b));
|
||||
let top = p + up * UP_PX * px;
|
||||
let stem = (p - up * DOWN_PX * px, top);
|
||||
match b {
|
||||
Bracket::Centre => vec![stem],
|
||||
Bracket::Start => vec![stem, (top, top + dir * FOOT_PX * px)],
|
||||
Bracket::End => vec![stem, (top, top - dir * FOOT_PX * px)],
|
||||
}
|
||||
}
|
||||
|
||||
/// The bracket within `tol` (document units) of `p`.
|
||||
fn hit(&self, p: Point, zoom: f64, tol: f64) -> Option<Bracket> {
|
||||
Bracket::ALL.into_iter().find(|b| self.lines(*b, zoom).iter().any(|(a, c)| Line::new(*a, *c).nearest(p, 1e-9).distance_sq <= tol * tol))
|
||||
}
|
||||
|
||||
/// The fraction of the path's length nearest document point `p`.
|
||||
fn fraction_at(&self, p: Point) -> Option<f64> {
|
||||
self.arc.fraction_at(self.xf.inverse() * p)
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected type on a path's bracket under `p`, if any.
|
||||
fn bracket_at(cx: &ToolContext, p: Point) -> Option<(Brackets, Bracket)> {
|
||||
Brackets::of(cx).into_iter().find_map(|b| {
|
||||
let which = b.hit(p, cx.zoom, cx.tol(5.0))?;
|
||||
Some((b, which))
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `p` over a bracket of the selected type on a path?
|
||||
pub fn over_bracket(cx: &ToolContext, p: Point) -> bool {
|
||||
bracket_at(cx, p).is_some()
|
||||
}
|
||||
|
||||
/// The brackets of the selected type on a path, in their layer's colour.
|
||||
pub fn overlays(cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = vec![];
|
||||
for b in Brackets::of(cx) {
|
||||
let color = cx.doc.layer_color(b.id);
|
||||
for which in Bracket::ALL {
|
||||
out.extend(b.lines(which, cx.zoom).into_iter().map(|(a, b)| Overlay::Line { a, b, color, dashed: false }));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Dragging a bracket of type on a path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BracketDrag {
|
||||
brackets: Brackets,
|
||||
which: Bracket,
|
||||
start: Point,
|
||||
/// Where the press was along the path (a fraction of its length).
|
||||
grab: f64,
|
||||
began: bool,
|
||||
}
|
||||
|
||||
impl BracketDrag {
|
||||
/// Start a drag when the press `ev` is on a bracket of the selected type on a path.
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let (brackets, which) = bracket_at(cx, ev.pos)?;
|
||||
let grab = brackets.fraction_at(ev.pos)?;
|
||||
Some(Self { brackets, which, start: ev.pos, grab, began: false })
|
||||
}
|
||||
|
||||
pub fn drag(&mut self, cx: &ToolContext, p: Point, cmd: bool) -> Vec<Action> {
|
||||
let mut out = vec![];
|
||||
if !self.began {
|
||||
if p.distance(self.start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
self.began = true;
|
||||
out.push(Action::Begin("Move Type on a Path".into()));
|
||||
}
|
||||
let Some(u) = self.brackets.fraction_at(p) else { return out };
|
||||
out.push(Action::Preview("type.pathOptions".into(), self.params(cx, p, u, cmd)));
|
||||
out
|
||||
}
|
||||
|
||||
/// `type.pathOptions` params for the pointer at `p`, `u` along the path.
|
||||
fn params(&self, cx: &ToolContext, p: Point, u: f64, cmd: bool) -> Value {
|
||||
let b = &self.brackets;
|
||||
let closed = b.arc.is_closed();
|
||||
let (s, e) = b.span;
|
||||
let mut m = Map::new();
|
||||
m.insert("ids".into(), json!([b.id.0]));
|
||||
match self.which {
|
||||
// Open paths keep the start before the end.
|
||||
Bracket::Start => {
|
||||
m.insert("start".into(), json!(if closed { u } else { u.min(e) }));
|
||||
}
|
||||
Bracket::End => {
|
||||
m.insert("end".into(), json!(if closed { u } else { u.max(s) }));
|
||||
}
|
||||
Bracket::Centre => {
|
||||
let mut d = u - self.grab;
|
||||
if closed {
|
||||
// The short way round.
|
||||
d -= d.round();
|
||||
}
|
||||
let place = |x: f64| if closed { x.rem_euclid(1.0) } else { x.clamp(0.0, 1.0) };
|
||||
let start = place(s + d);
|
||||
m.insert("start".into(), json!(start));
|
||||
if b.end_set {
|
||||
m.insert("end".into(), json!(if closed { place(e + d) } else { place(e + d).max(start) }));
|
||||
}
|
||||
// Across the path: the type flips to the other side.
|
||||
let (q, _, up) = b.frame(u);
|
||||
if !cmd && (p - q).dot(up) < -cx.tol(FLIP_PX) {
|
||||
m.insert("flip".into(), json!(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Vec<Action> {
|
||||
if self.began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use crate::{Mods, PointerKind};
|
||||
use vectorcraft_doc::Selection;
|
||||
|
||||
fn selected(id: NodeId) -> Selection {
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
s
|
||||
}
|
||||
|
||||
fn preview(a: &[Action]) -> &Value {
|
||||
let Some(Action::Preview(c, v)) = a.last() else { panic!("no preview: {a:?}") };
|
||||
assert_eq!(c, "type.pathOptions");
|
||||
v
|
||||
}
|
||||
|
||||
fn drag(cx: &ToolContext, from: Point, to: Point, mods: Mods) -> Vec<Action> {
|
||||
let mut d = BracketDrag::hit(cx, &PointerEvent::new(PointerKind::Down, from.x, from.y).with_mods(mods)).expect("a bracket");
|
||||
let a = d.drag(cx, to, mods.cmd);
|
||||
assert_eq!(a.first(), Some(&Action::Begin("Move Type on a Path".into())));
|
||||
assert_eq!(d.finish(), vec![Action::Commit]);
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brackets_stand_at_the_start_the_middle_and_the_end_of_the_span() {
|
||||
// A 300 pt path from (100, 300) with the type from 20 % on.
|
||||
let (d, id) = doc_with_path_type();
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let b = Brackets::of(&cx).pop().unwrap();
|
||||
assert_eq!(b.hit(Point::new(160.0, 290.0), 1.0, 2.0), Some(Bracket::Start));
|
||||
assert_eq!(b.hit(Point::new(280.0, 290.0), 1.0, 2.0), Some(Bracket::Centre));
|
||||
assert_eq!(b.hit(Point::new(400.0, 290.0), 1.0, 2.0), Some(Bracket::End));
|
||||
assert_eq!(b.hit(Point::new(220.0, 290.0), 1.0, 2.0), None);
|
||||
// Drawn over the type's side of the path, with feet pointing into the span.
|
||||
let o = overlays(&cx);
|
||||
assert_eq!(o.len(), 5);
|
||||
assert!(o.contains(&Overlay::Line { a: Point::new(160.0, 286.0), b: Point::new(164.0, 286.0), color: d.layer_color(id), dashed: false }));
|
||||
// Nothing for unselected or other type.
|
||||
assert!(Brackets::of(&crate::testutil::cx(&d, &Selection::default(), &p)).is_empty());
|
||||
let (d2, area) = doc_with_area_type();
|
||||
let s2 = selected(area);
|
||||
assert!(Brackets::of(&crate::testutil::cx(&d2, &s2, &p)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_centre_bracket_slides_the_type_and_across_the_path_flips_it() {
|
||||
let (d, id) = doc_with_path_type();
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let grab = Point::new(280.0, 295.0);
|
||||
// 60 pt along: 20 % further, the end (the end of the path) left alone.
|
||||
let v = preview(&drag(&cx, grab, Point::new(340.0, 295.0), Mods::default())).clone();
|
||||
assert!((v["start"].as_f64().unwrap() - 0.4).abs() < 1e-6, "{v}");
|
||||
assert!(v.get("end").is_none() && v.get("flip").is_none(), "{v}");
|
||||
assert_eq!(v["ids"], json!([id.0]));
|
||||
// Below the path: flipped as well.
|
||||
let v = preview(&drag(&cx, grab, Point::new(250.0, 320.0), Mods::default())).clone();
|
||||
assert!((v["start"].as_f64().unwrap() - 0.1).abs() < 1e-6 && v["flip"] == true, "{v}");
|
||||
// Cmd/Ctrl held: it only slides.
|
||||
let v = preview(&drag(&cx, grab, Point::new(250.0, 320.0), Mods { cmd: true, ..Mods::default() })).clone();
|
||||
assert!(v.get("flip").is_none(), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_start_and_end_brackets_moves_them() {
|
||||
let (d, id) = doc_with_path_type();
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let v = preview(&drag(&cx, Point::new(160.0, 295.0), Point::new(130.0, 280.0), Mods::default())).clone();
|
||||
assert!((v["start"].as_f64().unwrap() - 0.1).abs() < 1e-6 && v.get("end").is_none(), "{v}");
|
||||
let v = preview(&drag(&cx, Point::new(400.0, 295.0), Point::new(340.0, 310.0), Mods::default())).clone();
|
||||
assert!((v["end"].as_f64().unwrap() - 0.8).abs() < 1e-6 && v.get("start").is_none(), "{v}");
|
||||
// The end stops at the start.
|
||||
let v = preview(&drag(&cx, Point::new(400.0, 295.0), Point::new(110.0, 300.0), Mods::default())).clone();
|
||||
assert!((v["end"].as_f64().unwrap() - 0.2).abs() < 1e-6, "{v}");
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
//! drag a live rectangle's corner widget to round its corners (Alt-click cycles their kind,
|
||||
//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To
|
||||
//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
|
||||
//! click or drag a ruler guide ([`crate::rulerguide`]).
|
||||
//! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path
|
||||
//! ([`crate::pathtype`]).
|
||||
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
|
||||
//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of
|
||||
//! scaling its type: the text reflows at its size.
|
||||
@@ -17,6 +18,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::Targets;
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids};
|
||||
|
||||
@@ -49,6 +51,8 @@ enum State {
|
||||
},
|
||||
/// Dragging a Live Corners widget.
|
||||
Corner(CornerDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
Bracket(BracketDrag),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -181,11 +185,16 @@ impl Tool for SelectionTool {
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Down, _) => {
|
||||
// 1. Live Corners widgets, then the bounding-box handles of the current selection.
|
||||
// 1. Live Corners widgets and type on a path's brackets, then the bounding-box
|
||||
// handles of the current selection.
|
||||
if let Some(c) = CornerDrag::hit(cx, ev) {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(b) = BracketDrag::hit(cx, ev) {
|
||||
self.state = State::Bracket(b);
|
||||
return vec![];
|
||||
}
|
||||
if cx.show_bbox
|
||||
&& let Some(bx) = selection_box(cx)
|
||||
{
|
||||
@@ -296,6 +305,15 @@ impl Tool for SelectionTool {
|
||||
self.state = State::Idle;
|
||||
c.finish()
|
||||
}
|
||||
(PointerKind::Drag, State::Bracket(mut b)) => {
|
||||
let out = b.drag(cx, p, m.cmd);
|
||||
self.state = State::Bracket(b);
|
||||
out
|
||||
}
|
||||
(PointerKind::Up, State::Bracket(b)) => {
|
||||
self.state = State::Idle;
|
||||
b.finish()
|
||||
}
|
||||
(PointerKind::Up, State::Moving { began, deselect, .. }) => {
|
||||
self.state = State::Idle;
|
||||
self.measure = None;
|
||||
@@ -332,7 +350,7 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut o = vec![];
|
||||
let mut o = pathtype::overlays(cx);
|
||||
match &self.state {
|
||||
State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
|
||||
State::Corner(c) => o.extend(c.overlays(cx)),
|
||||
@@ -352,6 +370,7 @@ impl Tool for SelectionTool {
|
||||
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
|
||||
State::Moving { began: true, .. } => return Cursor::Arrow,
|
||||
State::Corner(_) => return Cursor::CornerRadius,
|
||||
State::Bracket(_) => return Cursor::PathBracket,
|
||||
_ => {}
|
||||
}
|
||||
if self.guide.busy() {
|
||||
@@ -360,6 +379,9 @@ impl Tool for SelectionTool {
|
||||
if over_widget(cx, p) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
if over_bracket(cx, p) {
|
||||
return Cursor::PathBracket;
|
||||
}
|
||||
if cx.show_bbox
|
||||
&& let Some(bx) = selection_box(cx)
|
||||
{
|
||||
|
||||
@@ -401,7 +401,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
use egui::CursorIcon as C;
|
||||
match c {
|
||||
Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius => C::Default,
|
||||
Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius | Cursor::PathBracket => C::Default,
|
||||
Cursor::Move => C::Move,
|
||||
Cursor::Crosshair => C::Crosshair,
|
||||
Cursor::ResizeH => C::ResizeHorizontal,
|
||||
@@ -972,10 +972,11 @@ fn node_outline(n: &Node) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
walk_drawn(n, &mut |c| match &c.kind {
|
||||
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
|
||||
// Area type shows its frame: the type area Direct Selection reshapes.
|
||||
NodeKind::Text(t) if c.perspective.is_none() && matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => {
|
||||
if let Some(frame) = t.area_frame() {
|
||||
bp.extend(frame.to_bezpath());
|
||||
// Area type shows its frame: the type area Direct Selection reshapes; type on a path its
|
||||
// path, which the selection tools' brackets sit on.
|
||||
NodeKind::Text(t) if c.perspective.is_none() && !matches!(t.kind, vectorcraft_doc::TextKind::Point) => {
|
||||
if let Some(path) = t.area_frame().or_else(|| t.type_path()) {
|
||||
bp.extend(path.to_bezpath());
|
||||
}
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
|
||||
@@ -1344,9 +1345,10 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
{
|
||||
anchor_square(p, xf.to_screen(b.center()), color, true, anchor(false));
|
||||
}
|
||||
// Text: baseline marker (area type shows its frame instead, which may not be a rectangle).
|
||||
// Point type: baseline marker (area type shows its frame instead, which may not be a
|
||||
// rectangle, and type on a path its path).
|
||||
if let NodeKind::Text(tx) = &n.kind
|
||||
&& !matches!(tx.kind, vectorcraft_doc::TextKind::Area { .. })
|
||||
&& matches!(tx.kind, vectorcraft_doc::TextKind::Point)
|
||||
{
|
||||
let o = xf.to_screen(tx.xf * Point::ZERO);
|
||||
let b = n.geometric_bounds().unwrap_or_default();
|
||||
|
||||
@@ -111,6 +111,21 @@ fn corner_radius(p: &Painter, o: Pos2) {
|
||||
p.add(Shape::line(pts, Stroke::new(1.2, INK)));
|
||||
}
|
||||
|
||||
/// Over a bracket of type on a path: the arrow with a bracket (a stem standing on a baseline,
|
||||
/// with a foot) below right.
|
||||
fn path_bracket(p: &Painter, o: Pos2) {
|
||||
arrow(p, o, false);
|
||||
let b = o + vec2(13.0, 13.0);
|
||||
let stem = [b + vec2(3.0, 0.0), b + vec2(3.0, 10.0)];
|
||||
let foot = [b + vec2(3.0, 0.0), b + vec2(7.0, 0.0)];
|
||||
let base = [b + vec2(0.0, 8.0), b + vec2(10.0, 8.0)];
|
||||
for (w, c) in [(3.0, HALO), (1.2, INK)] {
|
||||
for l in [stem, foot, base] {
|
||||
p.line_segment(l, Stroke::new(w, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The gradient annotator's stop cursors: the arrow with a plus (add a stop) or minus (delete it)
|
||||
/// badge.
|
||||
fn stop_badge(p: &Painter, o: Pos2, add: bool) {
|
||||
@@ -221,6 +236,7 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
|
||||
Cursor::WidthAdd => width(painter, p, "+"),
|
||||
Cursor::WidthPoint => width(painter, p, "point"),
|
||||
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(painter, p, c),
|
||||
Cursor::PathBracket => path_bracket(painter, p),
|
||||
_ => return false,
|
||||
}
|
||||
let _ = pos2;
|
||||
|
||||
@@ -10,8 +10,9 @@ use crate::state::Dialog;
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |d| tl!(&d.str("__label")).to_string(),
|
||||
body: |app, ui, d| {
|
||||
let lengths = lengths(&d.str("__command"));
|
||||
form::param_fields(ui, d, &|k| lengths.contains(&k), app.session.general_unit());
|
||||
let command = d.str("__command");
|
||||
let lengths = lengths(&command);
|
||||
form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), app.session.general_unit());
|
||||
false
|
||||
},
|
||||
confirm,
|
||||
@@ -27,10 +28,23 @@ fn lengths(command: &str) -> &'static [&'static str] {
|
||||
"perspective.grid.set" => &["cell", "distance"],
|
||||
"object.repeat.options" => &["radius", "hSpacing", "vSpacing"],
|
||||
"text.areaOptions" => &["width", "height", "gutter", "inset", "firstBaselineMin"],
|
||||
"type.pathOptions" => &["spacing"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameters of the commands this dialog edits that pick one of some values.
|
||||
fn choices(command: &str, key: &str) -> Option<form::Choices> {
|
||||
match (command, key) {
|
||||
("type.pathOptions", "effect") => Some(crate::menus::PATH_EFFECTS),
|
||||
("type.pathOptions", "alignToPath") => Some(PATH_ALIGN),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on a Path Options › Align to Path.
|
||||
const PATH_ALIGN: form::Choices = &[("Ascender", "ascender"), ("Descender", "descender"), ("Center", "center"), ("Baseline", "baseline")];
|
||||
|
||||
/// Closes before running, so a dialog the command opens stays open.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let cmd = d.str("__command");
|
||||
|
||||
@@ -131,7 +131,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let changed = match vectorcraft_plugins::effect::installed(&id) {
|
||||
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
|
||||
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
|
||||
None => form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), app.session.general_unit()),
|
||||
None => form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|_| None, app.session.general_unit()),
|
||||
};
|
||||
ui.add_space(6.0);
|
||||
let mut pv = d.bool("preview");
|
||||
|
||||
@@ -192,10 +192,19 @@ pub(super) fn params(d: &Dialog) -> Value {
|
||||
Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
}
|
||||
|
||||
/// The (label, value) choices of a parameter that picks one of some values.
|
||||
pub(super) type Choices = &'static [(&'static str, &'static str)];
|
||||
|
||||
/// Generic editor for command/effect parameters: numbers, booleans, strings and colours; the
|
||||
/// parameters `is_length` names are distances shown and typed in `unit`. Returns true when a
|
||||
/// value changed.
|
||||
pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog, is_length: &dyn Fn(&str) -> bool, unit: Unit) -> bool {
|
||||
/// parameters `is_length` names are distances shown and typed in `unit`, those `choices` gives
|
||||
/// choices for are dropdowns. Returns true when a value changed.
|
||||
pub(super) fn param_fields(
|
||||
ui: &mut egui::Ui,
|
||||
d: &mut Dialog,
|
||||
is_length: &dyn Fn(&str) -> bool,
|
||||
choices: &dyn Fn(&str) -> Option<Choices>,
|
||||
unit: Unit,
|
||||
) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut changed = false;
|
||||
egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
@@ -216,6 +225,17 @@ pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog, is_length: &dyn Fn
|
||||
ui.end_row();
|
||||
continue;
|
||||
}
|
||||
if let Some(options) = choices(&k) {
|
||||
let cur = v.as_str().unwrap_or_default();
|
||||
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| *l);
|
||||
let labels: Vec<&str> = options.iter().map(|(l, _)| *l).collect();
|
||||
if let Some((_, value)) = crate::widgets::dropdown(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
|
||||
d.fields.insert(k, json!(value));
|
||||
changed = true;
|
||||
}
|
||||
ui.end_row();
|
||||
continue;
|
||||
}
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
let mut x = n.as_f64().unwrap_or(0.0);
|
||||
@@ -318,6 +338,7 @@ pub(super) fn humanize(k: &str) -> String {
|
||||
"Radius1" => "Radius 1".into(),
|
||||
"Radius2" => "Radius 2".into(),
|
||||
"Include Cmy Blacks" => "Include Blacks with CMY:".into(),
|
||||
"Align To Path" => "Align to Path:".into(),
|
||||
_ => format!("{s}:"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
# language names, OpenType, the built-in workspace names and the bracketed perspective grid preset
|
||||
# names.
|
||||
About VectorCraft O aplikaci VectorCraft
|
||||
Align to Path Zarovnat k cestě
|
||||
Join Our Discord Připojit se k našemu Discordu
|
||||
Settings… Nastavení…
|
||||
Language Jazyk
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -182,6 +182,7 @@
|
||||
Artwork Colors Colores de la ilustración
|
||||
As Multiple Assets Como varios recursos
|
||||
As Single Asset Como un solo recurso
|
||||
Ascender Ascendente
|
||||
Ask When Modified Preguntar al modificar
|
||||
Aspect Ratio Proporción
|
||||
Asset Export Exportación de recursos
|
||||
@@ -212,6 +213,7 @@
|
||||
Background: Fondo:
|
||||
Backspace Retroceso
|
||||
Bar Graph Tool Herramienta Gráfico de barras
|
||||
Baseline Línea de base
|
||||
Baseline Shift Desplazamiento de línea de base
|
||||
Basic Latin Latín básico
|
||||
Batch Lote
|
||||
@@ -283,6 +285,7 @@
|
||||
Cap Extremo
|
||||
Cap: Extremo:
|
||||
Cascade Cascada
|
||||
Center Centro
|
||||
Center Artwork Centrar ilustración
|
||||
Center on Artboard Centrar en la mesa de trabajo
|
||||
Center-Justified Tab Tabulación centrada
|
||||
@@ -472,6 +475,8 @@
|
||||
Corners Esquinas
|
||||
Chamfer Chaflán
|
||||
Corners… Esquinas…
|
||||
Descender Descendente
|
||||
Flip Voltear
|
||||
Inverted Round Redondeada invertida
|
||||
Radius: Radio:
|
||||
Round Redondeada
|
||||
@@ -924,6 +929,7 @@
|
||||
Fit Headline Ajustar titular
|
||||
Decrease Font Size Reducir tamaño de fuente
|
||||
Increase Font Size Aumentar tamaño de fuente
|
||||
Spacing Espaciado
|
||||
Step Type Ajustar texto por incrementos
|
||||
Fit in View Encajar en la vista
|
||||
Fit in Window Encajar en la ventana
|
||||
@@ -2708,6 +2714,7 @@
|
||||
Type becomes outlines, and strokes a CAD line can't draw become filled shapes. El texto se convierte en contornos, y los trazos que una línea CAD no puede dibujar se convierten en formas rellenas.
|
||||
Type Object Selection by Path Only Selección de objetos de texto solo por trazado
|
||||
Type on a Path Texto en trazado
|
||||
Type on a Path Options Opciones de texto en trazado
|
||||
Type on a Path Options… Opciones de texto en trazado…
|
||||
Type on a Path Tool Herramienta Texto en trazado
|
||||
Type Orientation Orientación del texto
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -11,13 +11,18 @@
|
||||
About VectorCraft VectorCraft について
|
||||
All Scripts すべての文字
|
||||
Any Kind すべての種類
|
||||
Ascender アセンダ
|
||||
Baseline ベースライン
|
||||
Bottom-to-Bottom Leading 欧文ベースライン基準の行送り
|
||||
Center 中央
|
||||
Character Alignment 文字揃え
|
||||
Decorative 装飾
|
||||
Descender ディセンダ
|
||||
Em Box Bottom/Left 仮想ボディの下 / 左
|
||||
Em Box Center 仮想ボディの中央
|
||||
Em Box Top/Right 仮想ボディの上 / 右
|
||||
Favorites お気に入り
|
||||
Flip 反転
|
||||
Japanese 日本語
|
||||
Join Our Discord Discord に参加
|
||||
Line-end Punctuation Half Width 行末約物半角
|
||||
@@ -31,7 +36,9 @@
|
||||
Serif / Mincho 明朝・セリフ
|
||||
Settings… 環境設定…
|
||||
Language 表示言語
|
||||
Spacing 間隔
|
||||
Top-to-Top Leading 仮想ボディの上基準の行送り
|
||||
Type on a Path Options パス上文字オプション
|
||||
UI Brightness UI の明るさ
|
||||
Dark 暗い
|
||||
Medium Dark やや暗い
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -183,6 +183,7 @@
|
||||
Artwork Colors 图稿颜色
|
||||
As Multiple Assets 作为多个资源
|
||||
As Single Asset 作为单一资源
|
||||
Ascender 上缘
|
||||
Ask When Modified 修改时询问
|
||||
Aspect Ratio 长宽比
|
||||
Asset Export 资源导出
|
||||
@@ -214,6 +215,7 @@
|
||||
Background: 背景:
|
||||
Backspace Backspace
|
||||
Bar Graph Tool 条形图工具
|
||||
Baseline 基线
|
||||
Baseline Shift 基线偏移
|
||||
Basic Latin 基本拉丁文
|
||||
Batch 批处理
|
||||
@@ -471,6 +473,8 @@
|
||||
Corner Radius: 圆角半径:
|
||||
Corner: 转角:
|
||||
Corners 转角
|
||||
Descender 下缘
|
||||
Flip 翻转
|
||||
Round 圆角
|
||||
Inverted Round 反向圆角
|
||||
Corners… 转角…
|
||||
@@ -918,6 +922,7 @@
|
||||
Fit Headline 适合标题
|
||||
Decrease Font Size 减小字体大小
|
||||
Increase Font Size 增大字体大小
|
||||
Spacing 间距
|
||||
Step Type 按增量调整文字
|
||||
Fit in View 适合视图
|
||||
Fit in Window 适合窗口
|
||||
@@ -2647,6 +2652,7 @@
|
||||
Type becomes outlines, and strokes a CAD line can't draw become filled shapes. 文字变为轮廓,CAD 无法绘制的描边变为填充形状。
|
||||
Type Object Selection by Path Only 仅按路径选择文字对象
|
||||
Type on a Path 路径文字
|
||||
Type on a Path Options 路径文字选项
|
||||
Type on a Path Options… 路径文字选项…
|
||||
Type on a Path Tool 路径文字工具
|
||||
Type Orientation 文字方向
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -194,6 +194,7 @@
|
||||
Artwork Colors 圖稿顏色
|
||||
As Multiple Assets 作為多個資產
|
||||
As Single Asset 作為單一資產
|
||||
Ascender 上緣
|
||||
Ask When Modified 修改時詢問
|
||||
Aspect Ratio 長寬比
|
||||
Asset Export 資產匯出
|
||||
@@ -225,6 +226,7 @@
|
||||
Background: 背景:
|
||||
Backspace Backspace
|
||||
Bar Graph Tool 橫條圖工具
|
||||
Baseline 基線
|
||||
Baseline Shift 基線位移
|
||||
Basic Latin 基本拉丁文
|
||||
Batch 批次
|
||||
@@ -297,6 +299,7 @@
|
||||
Cap 端點
|
||||
Cap: 端點:
|
||||
Cascade 重疊顯示
|
||||
Center 置中
|
||||
Center Artwork 圖稿置中
|
||||
Center on Artboard 在工作區域置中
|
||||
Center-Justified Tab 置中對齊定位點
|
||||
@@ -484,6 +487,8 @@
|
||||
Corner Radius: 圓角半徑:
|
||||
Corner: 轉角:
|
||||
Corners 轉角
|
||||
Descender 下緣
|
||||
Flip 翻轉
|
||||
Round 圓角
|
||||
Radius: 半徑:
|
||||
Inverted Round 反向圓角
|
||||
@@ -940,6 +945,7 @@
|
||||
Fit Headline 符合標題
|
||||
Decrease Font Size 縮小字體大小
|
||||
Increase Font Size 放大字體大小
|
||||
Spacing 間距
|
||||
Step Type 依增量調整文字
|
||||
Fit in View 符合檢視
|
||||
Fit in Window 符合視窗
|
||||
@@ -2729,6 +2735,7 @@
|
||||
Type becomes outlines, and strokes a CAD line can't draw become filled shapes. 文字會變成外框,CAD 線條無法繪製的筆畫會變成填色形狀。
|
||||
Type Object Selection by Path Only 僅以路徑選取文字物件
|
||||
Type on a Path 路徑文字
|
||||
Type on a Path Options 路徑文字選項
|
||||
Type on a Path Options… 路徑文字選項…
|
||||
Type on a Path Tool 路徑文字工具
|
||||
Type Orientation 文字方向
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -86,6 +86,8 @@ mod tests_paintchips;
|
||||
#[cfg(test)]
|
||||
mod tests_pastechords;
|
||||
#[cfg(test)]
|
||||
mod tests_pathtype;
|
||||
#[cfg(test)]
|
||||
mod tests_pdfoutput;
|
||||
#[cfg(test)]
|
||||
mod tests_place;
|
||||
|
||||
+31
-13
@@ -2075,15 +2075,11 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
c("Area Type Options…", "text.areaOptions"),
|
||||
sub(
|
||||
"Type on a Path",
|
||||
vec![
|
||||
cp("Rainbow", "type.pathOptions", json!({"effect": "rainbow"})),
|
||||
cp("Skew", "type.pathOptions", json!({"effect": "skew"})),
|
||||
cp("3D Ribbon", "type.pathOptions", json!({"effect": "3dRibbon"})),
|
||||
cp("Stair Step", "type.pathOptions", json!({"effect": "stairStep"})),
|
||||
cp("Gravity", "type.pathOptions", json!({"effect": "gravity"})),
|
||||
Sep,
|
||||
cp("Type on a Path Options…", "type.pathOptions", json!({"start": 0})),
|
||||
],
|
||||
PATH_EFFECTS
|
||||
.iter()
|
||||
.map(|&(label, effect)| cp(label, "type.pathOptions", json!({ "effect": effect })))
|
||||
.chain([Sep, c("Type on a Path Options…", "type.pathOptions")])
|
||||
.collect(),
|
||||
),
|
||||
sub(
|
||||
"Threaded Text",
|
||||
@@ -2658,6 +2654,17 @@ pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) {
|
||||
(id.to_string(), if p.is_null() { json!({}) } else { p.clone() })
|
||||
}
|
||||
|
||||
/// The dialog of command `id` that shows the selection's current values (its query): its heading
|
||||
/// and the queried values it doesn't show. Type on a Path Options leaves the brackets where they
|
||||
/// are.
|
||||
fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> {
|
||||
Some(match id {
|
||||
"text.areaOptions" => ("Area Type Options", &[]),
|
||||
"type.pathOptions" => ("Type on a Path Options", &["start", "end"]),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The dialog (kind, fields) the menu item of command `id` opens, as the reference app's does.
|
||||
fn menu_dialog(id: &str) -> Option<(&'static str, Value)> {
|
||||
Some(match id {
|
||||
@@ -2728,11 +2735,17 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
|
||||
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields}));
|
||||
return;
|
||||
}
|
||||
// Area Type Options: a dialog with the selected area type's current values.
|
||||
if id == "text.areaOptions" && p.as_object().is_none_or(|o| o.is_empty()) {
|
||||
// Area Type Options and Type on a Path Options: dialogs with the selected type's current
|
||||
// values (the command's query), less those the dialog leaves alone.
|
||||
if let Some((label, hidden)) = queried_dialog(id)
|
||||
&& p.as_object().is_none_or(|o| o.is_empty())
|
||||
{
|
||||
match app.session.execute(id, &json!({})) {
|
||||
Ok(fields) => {
|
||||
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Area Type Options", "params": fields}));
|
||||
Ok(mut fields) => {
|
||||
if let Some(o) = fields.as_object_mut() {
|
||||
o.retain(|k, _| !hidden.contains(&k.as_str()));
|
||||
}
|
||||
let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields}));
|
||||
}
|
||||
Err(e) => app.status(e.to_string()),
|
||||
}
|
||||
@@ -2937,6 +2950,11 @@ const TYPE_SIZES: [(&str, u32); 14] = [
|
||||
("72 pt", 72),
|
||||
];
|
||||
|
||||
/// Type → Type on a Path effects (label, `type.pathOptions` effect), also the choices of its
|
||||
/// Options dialog.
|
||||
pub(crate) const PATH_EFFECTS: &[(&str, &str)] =
|
||||
&[("Rainbow", "rainbow"), ("Skew", "skew"), ("3D Ribbon", "3dRibbon"), ("Stair Step", "stairStep"), ("Gravity", "gravity")];
|
||||
|
||||
const INSERT_SPECIAL: &[(&str, &str)] = &[
|
||||
("Bullet", "bullet"),
|
||||
("Copyright Symbol", "copyright"),
|
||||
|
||||
@@ -668,16 +668,13 @@ fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opaci
|
||||
let lx = r.left() + EYE_W + 24.0 + if item.is_some() { 12.0 } else { 0.0 };
|
||||
let clicked = link(ui, pos2(lx, r.center().y), ("ap-op-link", item), tl!("Opacity:")) || resp.clicked();
|
||||
text(ui, pos2(lx + 56.0, r.center().y), &opacity_text(opacity, blend), false);
|
||||
egui::Popup::menu(&resp)
|
||||
.open_memory(clicked.then_some(egui::SetOpenCommand::Toggle))
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show(|ui| match widgets::opacity_blend(ui, ("ap-op", item), Some(opacity), Some(blend), true) {
|
||||
Some(TransparencyEdit::Blend(b)) => {
|
||||
app.run("transparency.set", json!({"item": item, "blend": b.label()})).ok();
|
||||
}
|
||||
Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase),
|
||||
None => {}
|
||||
});
|
||||
widgets::popover(&resp, clicked, |ui| match widgets::opacity_blend(ui, ("ap-op", item), Some(opacity), Some(blend), true) {
|
||||
Some(TransparencyEdit::Blend(b)) => {
|
||||
app.run("transparency.set", json!({"item": item, "blend": b.label()})).ok();
|
||||
}
|
||||
Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase),
|
||||
None => {}
|
||||
});
|
||||
r
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, ArrowAlign, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_doc::{Appearance, ArrowAlign, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, Unit, WidthProfile};
|
||||
use vectorcraft_engine::inspect::StrokeMixed;
|
||||
|
||||
use super::{character, current_stroke, pstate, set_pstate, stroke_mixed};
|
||||
@@ -12,8 +12,34 @@ use crate::theme::Tokens;
|
||||
use crate::widgets::{self, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
||||
pub const WEIGHT_PRESETS: [f64; 22] =
|
||||
/// Stroke weight dropdown presets in points, from the ladder of `unit` (Units > Stroke): each
|
||||
/// unit has its own ladder of round values in that unit, so the dropdown reads `0.25 mm`, not the
|
||||
/// `0.088 mm` a converted pt ladder gives. Feet, yards and meters, which no stroke is measured
|
||||
/// in, keep the pt ladder.
|
||||
pub fn weight_presets(unit: Unit) -> [f64; 22] {
|
||||
let native = match unit {
|
||||
Unit::Millimeters => MM_PRESETS,
|
||||
Unit::Centimeters => CM_PRESETS,
|
||||
Unit::Inches => IN_PRESETS,
|
||||
Unit::Pixels => PX_PRESETS,
|
||||
Unit::Picas => return PC_PRESETS,
|
||||
Unit::Points | Unit::FeetInches | Unit::Feet | Unit::Meters | Unit::Yards => return PT_PRESETS,
|
||||
};
|
||||
native.map(|v| unit.to_pt(v))
|
||||
}
|
||||
|
||||
const PT_PRESETS: [f64; 22] =
|
||||
[0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
|
||||
const MM_PRESETS: [f64; 22] = [0.1, 0.25, 0.35, 0.5, 0.75, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 15.0, 20.0, 25.0, 30.0];
|
||||
const CM_PRESETS: [f64; 22] = [0.01, 0.02, 0.03, 0.05, 0.06, 0.07, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0];
|
||||
const IN_PRESETS: [f64; 22] =
|
||||
[0.0078, 0.0156, 0.0313, 0.0625, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0];
|
||||
const PX_PRESETS: [f64; 22] =
|
||||
[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 30.0, 40.0];
|
||||
/// The pica ladder (0p1 … 5p) in points, so every entry is an exact pt weight. The dropdown shows
|
||||
/// them as decimal picas (`0.083 p`) until [`Unit::number`] writes `0p1` notation.
|
||||
const PC_PRESETS: [f64; 22] =
|
||||
[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 15.0, 18.0, 21.0, 24.0, 27.0, 30.0, 33.0, 36.0, 48.0, 60.0];
|
||||
|
||||
/// A profile's points as its silhouette: none (the plain bar) for the uniform stroke.
|
||||
fn silhouette(points: &[(f64, f64, f64)]) -> Option<&[(f64, f64, f64)]> {
|
||||
@@ -87,7 +113,8 @@ pub(crate) fn shown_weight(app: &VectorcraftApp, st: Option<&StrokeLayer>, mixed
|
||||
|
||||
/// The weight spinner (Stroke panel, Control bar): Units > Stroke, presets, blank when mixed.
|
||||
pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weight: Option<f64>, width: f32) {
|
||||
if let Some(w) = widgets::spin_field(ui, id, weight, app.session.stroke_unit(), width, 1.0, 0.0, &WEIGHT_PRESETS) {
|
||||
let unit = app.session.stroke_unit();
|
||||
if let Some(w) = widgets::spin_field(ui, id, weight, unit, width, 1.0, 0.0, &weight_presets(unit)) {
|
||||
set(app, json!({"weight": w}));
|
||||
}
|
||||
}
|
||||
@@ -95,7 +122,7 @@ pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weig
|
||||
/// The Stroke panel in a popover anchored to `resp` (the Control bar's and the Properties
|
||||
/// panel's Stroke links), which a click on it toggles.
|
||||
pub(crate) fn popover(app: &mut VectorcraftApp, resp: &egui::Response) {
|
||||
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
widgets::popover(resp, resp.clicked(), |ui| {
|
||||
ui.set_width(260.0);
|
||||
show(app, ui);
|
||||
});
|
||||
|
||||
@@ -168,6 +168,29 @@ fn item_opacity_popup_sets_the_items_opacity() {
|
||||
assert_eq!((n.appearance.items[0].opacity(), n.opacity), (1.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_popup_blend_dropdown_sets_the_blend_mode() {
|
||||
let mut app = app_with_rect();
|
||||
let ctx = egui::Context::default();
|
||||
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
|
||||
// The object's own Opacity row (the last one).
|
||||
let link = texts.iter().rev().find(|(t, _)| t == "Opacity:").expect("object opacity row").1;
|
||||
click(&ctx, &mut app, link.center(), appearance::show);
|
||||
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
|
||||
// Opening the dropdown inside the popup keeps the popup open: the list shows.
|
||||
click(&ctx, &mut app, text_rect(&texts, "Normal").center(), appearance::show);
|
||||
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
|
||||
click(&ctx, &mut app, text_rect(&texts, "Multiply").center(), appearance::show);
|
||||
assert_eq!(first_selected(&app).unwrap().blend, vectorcraft_color::BlendMode::Multiply);
|
||||
// The popup is still open after choosing.
|
||||
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
|
||||
assert!(texts.iter().any(|(t, _)| t == "Multiply"), "{texts:?}");
|
||||
// A frame without the panel closes it, as egui closes the popups it remembers.
|
||||
frame_events(&ctx, &mut app, vec![], |_, _| {});
|
||||
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
|
||||
assert!(!texts.iter().any(|(t, _)| t == "Multiply"), "{texts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_row_names_the_selection_like_the_control_bar() {
|
||||
let mut app = app_with_rect();
|
||||
|
||||
@@ -32,10 +32,10 @@ fn weights_and_dashes_show_in_the_stroke_unit_everywhere() {
|
||||
app.session.prefs.units_stroke = "millimeters".into();
|
||||
run(&mut app, "stroke.set", json!({"dash": [12, 6]}));
|
||||
let mm = vectorcraft_doc::Unit::Millimeters.format(1.0);
|
||||
assert_eq!(mm, "0.353 mm");
|
||||
assert_eq!(mm, "0.3528 mm");
|
||||
let t = texts(&ctx, &mut app, stroke::show);
|
||||
assert!(shows(&t, &mm), "Stroke panel: {t:?}");
|
||||
assert!(shows(&t, "4.233") && shows(&t, "2.117"), "dash and gap in mm: {t:?}");
|
||||
assert!(shows(&t, "4.2333") && shows(&t, "2.1167"), "dash and gap in mm: {t:?}");
|
||||
assert!(shows(&texts(&ctx, &mut app, properties::show), &mm), "Properties panel");
|
||||
assert!(shows(&texts(&ctx, &mut app, crate::chrome::control_bar), &mm), "Control bar");
|
||||
}
|
||||
@@ -75,7 +75,8 @@ fn inside_and_outside_are_off_for_open_paths_and_type() {
|
||||
|
||||
#[test]
|
||||
fn the_weight_presets_run_from_a_quarter_point_to_a_hundred() {
|
||||
assert!(stroke::WEIGHT_PRESETS.contains(&0.25) && stroke::WEIGHT_PRESETS.contains(&100.0));
|
||||
let pt = stroke::weight_presets(vectorcraft_doc::Unit::Points);
|
||||
assert!(pt.contains(&0.25) && pt.contains(&100.0));
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_rect();
|
||||
// The chevron at the right end of the 120 px weight spinner opens them.
|
||||
@@ -86,3 +87,28 @@ fn the_weight_presets_run_from_a_quarter_point_to_a_hundred() {
|
||||
let t = texts(&ctx, &mut app, stroke::show);
|
||||
assert!(shows(&t, "0.25 pt") && shows(&t, "100 pt"), "{t:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weight_presets_display_cleanly_in_their_native_unit() {
|
||||
use vectorcraft_doc::Unit;
|
||||
// Round-tripping a native-unit value through to_pt → from_pt → number() must give the same
|
||||
// string the ladder was authored with: no "0.353 mm" surprises in the dropdown.
|
||||
for (unit, want) in [
|
||||
(Unit::Millimeters, ["0.1", "0.25", "0.35", "0.5", "0.75", "1", "30"].as_slice()),
|
||||
(Unit::Centimeters, ["0.01", "0.05", "0.1", "0.5", "1", "5"].as_slice()),
|
||||
(Unit::Pixels, ["1", "10", "20", "40"].as_slice()),
|
||||
(Unit::Inches, ["0.0078", "0.0313", "0.375", "1", "5"].as_slice()),
|
||||
(Unit::Points, ["0.25", "0.5", "1", "50", "100"].as_slice()),
|
||||
] {
|
||||
let presets = stroke::weight_presets(unit);
|
||||
let shown: Vec<String> = presets.iter().map(|pt| unit.number(*pt)).collect();
|
||||
for w in want {
|
||||
assert!(shown.iter().any(|s| s == w), "{unit:?} preset {w:?} missing from {shown:?}");
|
||||
}
|
||||
}
|
||||
// Picas are exact pt weights (0p1 = 1 pt, 1p = 12 pt, 5p = 60 pt).
|
||||
let pc = stroke::weight_presets(Unit::Picas);
|
||||
assert!([1.0, 12.0, 60.0].iter().all(|w| pc.contains(w)), "{pc:?}");
|
||||
// Units no stroke is measured in keep the pt ladder, in points.
|
||||
assert_eq!(stroke::weight_presets(Unit::Meters), stroke::weight_presets(Unit::Points));
|
||||
}
|
||||
@@ -37,7 +37,7 @@ fn general_mm(app: &mut VectorcraftApp) {
|
||||
#[test]
|
||||
fn panels_show_millimetres_after_the_preference_changes() {
|
||||
let mut app = app_with_rect();
|
||||
assert_eq!(side_mm(), "35.278 mm");
|
||||
assert_eq!(side_mm(), "35.2778 mm");
|
||||
assert!(shows(&texts(&mut app, transform::show), "100 pt"), "points first");
|
||||
general_mm(&mut app);
|
||||
// The next frame: no restart, nothing cached.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Type → Type on a Path → Type on a Path Options… (#429): the dialog shows the selected type's
|
||||
//! options (Effect, Flip, Align to Path, Spacing) and OK leaves its brackets where they are.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, TextKind};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::{VectorcraftApp, dialogs, menus};
|
||||
|
||||
/// The start and end brackets of type on a path `id`.
|
||||
fn brackets(app: &VectorcraftApp, id: NodeId) -> (f64, Option<f64>) {
|
||||
match app.session.doc().unwrap().doc.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => match t.kind {
|
||||
TextKind::OnPath { start, end, .. } => (start, end),
|
||||
ref k => panic!("not type on a path: {k:?}"),
|
||||
},
|
||||
k => panic!("not text: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open Type on a Path Options… from its menu item.
|
||||
fn open_options(app: &mut VectorcraftApp) {
|
||||
let entry = menus::menu_entries(app).into_iter().find(|e| e.label == "Type on a Path Options…").expect("menu item");
|
||||
assert_eq!(entry.path, ["Type", "Type on a Path"]);
|
||||
assert!(entry.enabled);
|
||||
let (cmd, params) = menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params);
|
||||
menus::invoke(app, &cmd, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_on_a_path_options_keep_the_type_where_it_is() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
|
||||
let path = app.run("path.create", json!({"d": "M100 200 L500 200"})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [300, 210]})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
app.run("select.set", json!({"ids": [id.0]})).unwrap();
|
||||
let (start, _) = brackets(&app, id);
|
||||
assert!((start - 0.5).abs() < 1e-3, "{start}");
|
||||
|
||||
open_options(&mut app);
|
||||
let d = app.ui.dialog.as_ref().expect("a dialog opens");
|
||||
assert_eq!((d.str("__command"), d.str("__label")), ("type.pathOptions".to_string(), "Type on a Path Options".to_string()));
|
||||
for (k, v) in [("effect", json!("rainbow")), ("flip", json!(false)), ("alignToPath", json!("baseline")), ("spacing", json!(0.0))] {
|
||||
assert_eq!(d.fields.get(k), Some(&v), "{k}");
|
||||
}
|
||||
assert!(!d.fields.contains_key("start") && !d.fields.contains_key("end"), "the brackets aren't the dialog's");
|
||||
// It draws headlessly: the choices by name.
|
||||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| dialogs::show(app, ui.ctx()));
|
||||
for label in ["Type on a Path Options", "Effect:", "Rainbow", "Flip:", "Align to Path:", "Baseline", "Spacing:"] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
// OK as it opened: nothing moves.
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(brackets(&app, id), (start, None));
|
||||
|
||||
// Flip and Align to Path: the type turns to the other side of the same stretch of its path.
|
||||
open_options(&mut app);
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("flip".into(), Value::Bool(true));
|
||||
d.fields.insert("alignToPath".into(), json!("center"));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
let (s, e) = brackets(&app, id);
|
||||
assert!(s.abs() < 1e-9 && e.is_some_and(|e| (e - (1.0 - start)).abs() < 1e-9), "{s} {e:?}");
|
||||
// The dialog shows the new alignment, and Flip is unchecked again (it flips once more).
|
||||
open_options(&mut app);
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.fields.get("alignToPath"), d.fields.get("flip")), (Some(&json!("center")), Some(&json!(false))));
|
||||
}
|
||||
@@ -559,6 +559,39 @@ pub(crate) fn combo<R>(
|
||||
.inner
|
||||
}
|
||||
|
||||
/// A popover anchored to `resp`, opened and closed by `toggle` (usually `resp.clicked()`), and
|
||||
/// closed by Escape, a click outside it and outside any popup it opened, or a frame in which its
|
||||
/// anchor isn't shown. Use it instead of `egui::Popup::menu` when the popover holds dropdowns or
|
||||
/// popups of its own: egui remembers one open popup at a time, so opening a dropdown inside a
|
||||
/// remembered popup would close the popup (and the dropdown with it). This one keeps its open
|
||||
/// state itself: the last frame it was open in.
|
||||
pub fn popover<R>(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) -> R) -> Option<R> {
|
||||
let ctx = &resp.ctx;
|
||||
let id = resp.id.with("popover");
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
let was_open = ctx.data(|d| d.get_temp::<u64>(id)).is_some_and(|f| f + 1 >= frame);
|
||||
let mut open = if toggle {
|
||||
!was_open
|
||||
} else {
|
||||
// Popups (this one and those it opened) are on foreground layers; a click anywhere
|
||||
// else closes it.
|
||||
let clicked_elsewhere = ctx
|
||||
.input(|i| i.pointer.any_click().then(|| i.pointer.interact_pos()).flatten())
|
||||
.is_some_and(|pos| ctx.layer_id_at(pos).is_none_or(|l| l.order != egui::Order::Foreground));
|
||||
was_open && !clicked_elsewhere
|
||||
};
|
||||
let inner =
|
||||
egui::Popup::menu(resp).id(id).open_bool(&mut open).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(content).map(|r| r.inner);
|
||||
ctx.data_mut(|d| {
|
||||
if open {
|
||||
d.insert_temp(id, frame);
|
||||
} else {
|
||||
d.remove::<u64>(id);
|
||||
}
|
||||
});
|
||||
inner
|
||||
}
|
||||
|
||||
/// The body of a menu or popup list: as tall as its items up to the bottom of the window, and
|
||||
/// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on
|
||||
/// small windows.
|
||||
|
||||
+19
@@ -2029,6 +2029,25 @@ undo step. Object › Transform › Scale, the Scale tool and the Transform pane
|
||||
{"name":"run_command","arguments":{"command":"text.reshapeArea","params":{"id":42,"anchors":[[0,2]],"dx":40,"dy":60}}}
|
||||
```
|
||||
|
||||
## Moving and flipping type on a path
|
||||
|
||||
Type on a path flows between a start and an end bracket, stored as fractions of its path's length.
|
||||
`type.pathOptions {start?, end?, flip?, effect?, alignToPath?, spacing?, ids?}` sets them and the rest of Type on a
|
||||
Path Options: `end: null` puts the end bracket back at the end of the path (or once round a closed one); `flip`
|
||||
then turns the type to the other side of its path, the path running the other way and the brackets swapping ends so
|
||||
the type keeps its stretch of the path; `alignToPath` runs the `ascender`, `descender`, `center` or `baseline` (the
|
||||
default) along the path; `spacing` (points) closes glyphs up round the outside of curves and opens them up round the
|
||||
inside. With none of them it queries the first selected type on a path:
|
||||
`{start, end, flip: false, effect, alignToPath, spacing}`. The Options dialog shows Effect, Flip, Align to Path and
|
||||
Spacing and leaves the brackets where they are. As in the reference app, the Selection and Direct Selection tools
|
||||
show selected type on a path's brackets: dragging the start or end bracket (`pointer_gesture`) sets where the type
|
||||
begins or ends, dragging the centre bracket slides the type along its path and, dragged across the path, flips it
|
||||
(with Cmd/Ctrl held it only slides). Each is one undo step.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}}
|
||||
```
|
||||
|
||||
## Constrain proportions
|
||||
|
||||
The link between W and H in the Transform panel, the Properties panel and the Control bar is the
|
||||
|
||||
Reference in new issue
Block a user