Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
517b166722 | ||
|
|
377f236687 | ||
|
|
f7c88af1de | ||
|
|
70b1bbf679 |
No files matched your search
+4
-4
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -107,13 +107,13 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- Four brightness themes, persistent preferences.
|
||||
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 35 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Construction and Spacing Guides, performance, devices…).
|
||||
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
|
||||
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
|
||||
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
|
||||
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
|
||||
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
|
||||
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too. Not yet: Select All across threaded frames (one frame's text is selected).
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
- **Tools:**
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
|
||||
@@ -128,7 +128,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
|
||||
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles: the dragged edges align with the other objects; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), and Draw Normal / Behind / Inside modes.
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472).
|
||||
- **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. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
|
||||
|
||||
@@ -27,10 +27,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("select.nextBelow", "Next Object Below", ["Select"], Some("Cmd+Alt+["), "{}", has_selection, |s, _| step(s, -1)),
|
||||
cmd!("select.set", "Select Objects", [], None, "{ids: [id…]}", has_doc, set),
|
||||
cmd!("select.add", "Add to Selection", [], None, "{ids: [id…]}", has_doc, add),
|
||||
cmd!("select.toggle", "Toggle Selection", [], None, "{id}", has_doc, toggle),
|
||||
cmd!(
|
||||
"select.toggle",
|
||||
"Toggle Selection",
|
||||
[],
|
||||
None,
|
||||
"{id} or {ids: [id…]}: each selected one leaves the selection, the others join it",
|
||||
has_doc,
|
||||
toggle
|
||||
),
|
||||
cmd!("select.key", "Set Key Object", [], None, "{id?} (none clears)", has_doc, key),
|
||||
cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors),
|
||||
cmd!("select.anchorsMany", "Select Anchors", [], None, "{items: [{id, anchors}], add?: bool}", has_doc, anchors_many),
|
||||
cmd!(
|
||||
"select.anchorsMany",
|
||||
"Select Anchors",
|
||||
[],
|
||||
None,
|
||||
"{items: [{id, anchors}], mode?: \"set\"|\"add\"|\"toggle\"|\"subtract\" (set by default; add: true is mode add)}: toggle selects the anchors not selected and deselects the others, subtract deselects them; a path left with none leaves the selection",
|
||||
has_doc,
|
||||
anchors_many
|
||||
),
|
||||
cmd!(
|
||||
"select.same.fillColor",
|
||||
"Fill Color",
|
||||
@@ -217,8 +233,12 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("select.toggle", "missing id"))?;
|
||||
s.select(|_, sel| sel.toggle(id))?;
|
||||
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|id| vec![id])).ok_or_else(|| bad("select.toggle", "missing id or ids"))?;
|
||||
s.select(|_, sel| {
|
||||
for id in ids {
|
||||
sel.toggle(id);
|
||||
}
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -262,16 +282,48 @@ fn anchors_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|it| Some((NodeId(it.get("id")?.as_u64()?), parse_refs(it.get("anchors")).into_iter().collect()))).collect())
|
||||
.unwrap_or_default();
|
||||
let add = bool_or(p, "add", false);
|
||||
// `add: true` is the older spelling of mode add.
|
||||
let mode = match str_param(p, "mode") {
|
||||
Some(m @ ("set" | "add" | "toggle" | "subtract")) => m,
|
||||
Some(m) => return Err(bad("select.anchorsMany", format!("unknown mode {m:?}"))),
|
||||
None if bool_or(p, "add", false) => "add",
|
||||
None => "set",
|
||||
};
|
||||
s.select(|d, sel| {
|
||||
if !add {
|
||||
if mode == "set" {
|
||||
sel.clear();
|
||||
}
|
||||
for (id, refs) in items {
|
||||
let total = d.node(id).and_then(|n| n.path_data()).map(|p| p.anchor_count()).unwrap_or(0);
|
||||
let all: BTreeSet<(usize, usize)> =
|
||||
d.node(id).and_then(|n| n.path_data()).map(|p| p.anchors().map(|(s, i, _)| (s, i)).collect()).unwrap_or_default();
|
||||
// Anything but a path is selected, toggled or deselected as a whole.
|
||||
if all.is_empty() {
|
||||
match mode {
|
||||
"toggle" => sel.toggle(id),
|
||||
"subtract" => sel.remove(id),
|
||||
_ => sel.add(id),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// From the anchors selected now (all of them for a path selected as a whole).
|
||||
let now = match sel.partial(id) {
|
||||
Some(a) => a.clone(),
|
||||
None if sel.contains(id) => all.clone(),
|
||||
None => BTreeSet::new(),
|
||||
};
|
||||
let refs: BTreeSet<(usize, usize)> = refs.intersection(&all).copied().collect();
|
||||
let next: BTreeSet<(usize, usize)> = match mode {
|
||||
"toggle" => now.symmetric_difference(&refs).copied().collect(),
|
||||
"subtract" => now.difference(&refs).copied().collect(),
|
||||
_ => now.union(&refs).copied().collect(),
|
||||
};
|
||||
if next.is_empty() {
|
||||
sel.remove(id);
|
||||
continue;
|
||||
}
|
||||
sel.add(id);
|
||||
if refs.len() < total {
|
||||
sel.anchors.entry(id).or_default().extend(refs);
|
||||
if next.len() < all.len() {
|
||||
sel.anchors.insert(id, next);
|
||||
} else {
|
||||
sel.anchors.remove(&id);
|
||||
}
|
||||
|
||||
@@ -338,6 +338,72 @@ fn lasso_selects_anchor_subset() {
|
||||
assert_eq!(st.selection.partial(a).map(|p| p.len()), Some(2));
|
||||
}
|
||||
|
||||
/// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are
|
||||
/// deselected and the others selected, the rest of the selection staying as it was.
|
||||
#[test]
|
||||
fn selection_shift_marquee_toggles_objects() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
let b = rect(&mut s, 200.0, 100.0, 50.0, 50.0);
|
||||
let c = rect(&mut s, 300.0, 100.0, 50.0, 50.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
gesture(&mut s, "selection", &[(180.0, 80.0), (300.0, 200.0), (380.0, 200.0)], shift);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a, c]);
|
||||
// Again over all three: a leaves, b joins, c leaves.
|
||||
gesture(&mut s, "selection", &[(80.0, 80.0), (300.0, 200.0), (380.0, 200.0)], shift);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
|
||||
// Without Shift the marquee replaces the selection.
|
||||
gesture(&mut s, "selection", &[(80.0, 80.0), (300.0, 200.0), (260.0, 200.0)], Mods::default());
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]);
|
||||
}
|
||||
|
||||
/// Shift-drag a marquee with Direct Selection (#483): the anchors inside toggle; a path left with
|
||||
/// none of them leaves the selection, one with all of them is selected whole again.
|
||||
#[test]
|
||||
fn direct_selection_shift_marquee_toggles_anchors() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
let b = rect(&mut s, 300.0, 100.0, 100.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
// Around a's top edge (two anchors, selected) and b's top-left anchor (not selected).
|
||||
let top = [(90.0, 90.0), (310.0, 110.0), (310.0, 110.0)];
|
||||
gesture(&mut s, "directSelection", &top, shift);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.objects, vec![a, b]);
|
||||
assert_eq!(st.selection.partial(a).map(|p| p.len()), Some(2), "a's bottom anchors stay");
|
||||
assert_eq!(st.selection.partial(b).map(|p| p.len()), Some(1));
|
||||
// The same again: a whole once more, b out.
|
||||
gesture(&mut s, "directSelection", &top, shift);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.objects, vec![a]);
|
||||
assert_eq!(st.selection.partial(a), None);
|
||||
// Group Selection's marquee toggles the same way.
|
||||
gesture(&mut s, "groupSelection", &top, shift);
|
||||
assert_eq!(s.doc().unwrap().selection.partial(a).map(|p| p.len()), Some(2));
|
||||
}
|
||||
|
||||
/// The Lasso: Shift adds anchors, Alt takes them away, and neither drops the rest of the
|
||||
/// selection (a path selected whole stays whole when its anchors are added again).
|
||||
#[test]
|
||||
fn lasso_shift_adds_and_alt_subtracts() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
let t = s.execute("text.create", &json!({"x": 300, "y": 300, "text": "Hi"})).unwrap();
|
||||
let t = NodeId(t["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap();
|
||||
// A loop round a's top-left anchor.
|
||||
let corner = [(90.0, 90.0), (110.0, 90.0), (110.0, 110.0), (90.0, 110.0), (90.0, 110.0)];
|
||||
gesture(&mut s, "lasso", &corner, Mods { shift: true, ..Default::default() });
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.selection.objects.clone(), st.selection.partial(a)), (vec![a, t], None));
|
||||
gesture(&mut s, "lasso", &corner, Mods { alt: true, ..Default::default() });
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.objects, vec![a, t], "the type stays selected");
|
||||
assert_eq!(st.selection.partial(a).map(|p| p.len()), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_tool_leaves_document_untouched() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -180,6 +180,10 @@ fn structured_junk() {
|
||||
("select.add", json!({"ids": [[1], {"a": 1}]})),
|
||||
("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})),
|
||||
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})),
|
||||
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000], [0, 0]]}, {"id": u64::MAX}], "mode": "toggle"})),
|
||||
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 0]]}], "mode": "subtract"})),
|
||||
("select.anchorsMany", json!({"items": [], "mode": 7})),
|
||||
("select.toggle", json!({"ids": [2, u64::MAX, "x"]})),
|
||||
("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})),
|
||||
("object.transform", json!({"matrix": [1e308, 1e308, 1e308, 1e308, 1e308, 1e308]})),
|
||||
("object.transform", json!({"matrix": [1, 2, 3]})),
|
||||
|
||||
@@ -539,6 +539,30 @@ fn selection_preferences_apply_to_pointer_gestures() {
|
||||
assert_eq!(click(&mut s, 7, 250.0, json!({})), json!([front]), "the path does");
|
||||
}
|
||||
|
||||
/// A Shift-drag marquee reaches agents (#483): `pointer_gesture` with `mods.shift` toggles the
|
||||
/// objects it reaches, so a selected one leaves the selection and an unselected one joins it.
|
||||
#[test]
|
||||
fn shift_marquee_gesture_toggles_the_selection() {
|
||||
let mut s = server();
|
||||
let square = |s: &mut Server, id: u64, x: u64| {
|
||||
let r = call(s, id, "draw_shape", json!({"shape": "rectangle", "x": x, "y": 100, "width": 50, "height": 50}));
|
||||
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let (a, b, c) = (square(&mut s, 1, 100), square(&mut s, 2, 200), square(&mut s, 3, 300));
|
||||
let r = call(&mut s, 4, "run_command", json!({"command": "select.set", "params": {"ids": [a, b]}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let events = json!([{"kind": "down", "x": 180, "y": 80}, {"kind": "drag", "x": 300, "y": 200}, {"kind": "up", "x": 380, "y": 200}]);
|
||||
for tool in ["selection", "directSelection"] {
|
||||
let r = call(&mut s, 5, "pointer_gesture", json!({"tool": tool, "events": events, "mods": {"shift": true}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let sel = &serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"];
|
||||
// The Selection tool takes b out and adds c; Direct Selection's marquee then toggles their
|
||||
// anchors back: b's are selected again and c, whole, leaves.
|
||||
let want = if tool == "selection" { json!([a, c]) } else { json!([a, b]) };
|
||||
assert_eq!(*sel, want, "{tool}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type preferences reach agents (#394): type the Type tool places starts with placeholder text,
|
||||
/// selected; Alt+→ tracks it by Tracking and Cmd+Shift+. steps its size by Size/Leading.
|
||||
#[test]
|
||||
|
||||
+31
-11
@@ -5,10 +5,12 @@
|
||||
//! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45°
|
||||
//! steps), drag a direction handle to
|
||||
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
|
||||
//! it), marquee to select anchors, drag a live rectangle's corner widget to round its corners
|
||||
//! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are
|
||||
//! deselected, the others selected), drag a live rectangle's corner widget to round its corners
|
||||
//! (the selected ones when anchors are selected; Alt-click cycles their kind, double-click opens
|
||||
//! the Corners dialog).
|
||||
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group.
|
||||
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group;
|
||||
//! its marquee works as Direct Selection's.
|
||||
//! Both move objects selected as a whole as the Selection tool does, snapping alike.
|
||||
//! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]).
|
||||
//! Direct Selection also edits a blend's spine: drag its points (a key object on a point moves
|
||||
@@ -69,7 +71,8 @@ enum State {
|
||||
Marquee {
|
||||
start: Point,
|
||||
cur: Point,
|
||||
add: bool,
|
||||
/// Shift: the anchors inside leave the selection if selected, else join it.
|
||||
toggle: bool,
|
||||
},
|
||||
Corner(CornerDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
@@ -285,7 +288,7 @@ impl Tool for DirectSelectionTool {
|
||||
return out;
|
||||
}
|
||||
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else {
|
||||
self.state = State::Marquee { start: p, cur: p, add: ev.mods.shift };
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift };
|
||||
return vec![];
|
||||
};
|
||||
// Walk up from the leaf: select the first ancestor not yet selected (below the layer).
|
||||
@@ -393,7 +396,7 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
self.state = State::Marquee { start: p, cur: p, add: ev.mods.shift };
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift };
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Drag, State::MoveAnchors { start, grab, began }) => {
|
||||
@@ -497,8 +500,8 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::Idle;
|
||||
self.mesh.release().unwrap_or_default()
|
||||
}
|
||||
(PointerKind::Drag, State::Marquee { start, add, .. }) => {
|
||||
self.state = State::Marquee { start, cur: p, add };
|
||||
(PointerKind::Drag, State::Marquee { start, toggle, .. }) => {
|
||||
self.state = State::Marquee { start, cur: p, toggle };
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Drag, State::Corner(mut c)) => {
|
||||
@@ -537,11 +540,11 @@ impl Tool for DirectSelectionTool {
|
||||
self.guides.clear();
|
||||
vec![Action::Commit]
|
||||
}
|
||||
(PointerKind::Up, State::Marquee { start, add, .. }) => {
|
||||
(PointerKind::Up, State::Marquee { start, toggle, .. }) => {
|
||||
self.state = State::Idle;
|
||||
let r = Rect::from_points(start, p);
|
||||
if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) {
|
||||
return if add { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||||
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||||
}
|
||||
// Collect anchors inside the rect for every editable path.
|
||||
let mut sel: Vec<(NodeId, Vec<AnchorRef>)> = vec![];
|
||||
@@ -555,7 +558,7 @@ impl Tool for DirectSelectionTool {
|
||||
});
|
||||
sel.retain(|(id, _)| cx.doc.is_editable(*id));
|
||||
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
|
||||
vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "add": add}))]
|
||||
vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}))]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
@@ -792,8 +795,25 @@ mod tests {
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "add": false}))]);
|
||||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"}))]);
|
||||
}
|
||||
/// Shift-drag a marquee: the anchors inside toggle (#483), with Group Selection too.
|
||||
#[test]
|
||||
fn shift_marquee_toggles_anchors() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
for group in [false, true] {
|
||||
let mut t = DirectSelectionTool::new(group);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0).with_mods(shift));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0).with_mods(shift));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "toggle"}))]);
|
||||
}
|
||||
}
|
||||
|
||||
/// An open path from (100, 300) to (200, 300) arching up through (150, 262.5).
|
||||
fn arch_doc() -> (vectorcraft_doc::Document, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
|
||||
+86
-25
@@ -271,8 +271,8 @@ impl Targets {
|
||||
/// Snap a single point: onto the nearest anchor or centre, else onto the nearest segment, else
|
||||
/// into line with targets. Returns the snapped point and guide overlays.
|
||||
pub fn snap_point(&self, p: Point, tol: f64) -> (Point, Vec<Overlay>) {
|
||||
if let Some((q, k)) = self.points.iter().filter(|(q, _)| q.distance(p) <= tol).min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p))) {
|
||||
return (*q, self.label(*q, k.label()).into_iter().collect());
|
||||
if let Some((q, k)) = self.nearest_point(p, tol) {
|
||||
return (q, self.label(q, k.label()).into_iter().collect());
|
||||
}
|
||||
if let Some(q) = self.on_segment(p, tol) {
|
||||
return (q, self.label(q, Kind::Edge.label()).into_iter().collect());
|
||||
@@ -302,6 +302,11 @@ impl Targets {
|
||||
(out, ov)
|
||||
}
|
||||
|
||||
/// The anchor or centre nearest `p`, within `tol`.
|
||||
fn nearest_point(&self, p: Point, tol: f64) -> Option<(Point, Kind)> {
|
||||
self.points.iter().copied().filter(|(q, _)| q.distance(p) <= tol).min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p)))
|
||||
}
|
||||
|
||||
/// The nearest point within `tol` of `p` on a target segment.
|
||||
fn on_segment(&self, p: Point, tol: f64) -> Option<Point> {
|
||||
self.segments
|
||||
@@ -334,7 +339,7 @@ impl Targets {
|
||||
/// bleed edge beyond it): onto an anchor or centre near the point itself, otherwise into line
|
||||
/// with targets, each axis on whichever of them comes nearest.
|
||||
pub fn snap_point_with(&self, p: Point, offsets: &[Vec2], tol: f64) -> (Point, Vec<Overlay>) {
|
||||
if self.points.iter().any(|(q, _)| q.distance(p) <= tol) {
|
||||
if self.nearest_point(p, tol).is_some() {
|
||||
return self.snap_point(p, tol);
|
||||
}
|
||||
let rects: Vec<Rect> = std::iter::once(Vec2::ZERO).chain(offsets.iter().copied()).map(|o| Rect::from_points(p + o, p + o)).collect();
|
||||
@@ -390,11 +395,14 @@ impl Targets {
|
||||
|
||||
/// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for
|
||||
/// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own
|
||||
/// frame, and so is the result. The corners and the side that handle moves land on the nearest
|
||||
/// target within `tol` on the page, whatever the box's angle. A handle with one way to go (a
|
||||
/// side, or a proportional corner) slides along it until one of them is on a target.
|
||||
/// frame, and so is the result. The handle lands on an anchor or centre within `tol` on the page
|
||||
/// (labelled as [`Self::snap_point`] does), else the corners and the side it moves line up with
|
||||
/// the nearest target ("align"), whatever the box's angle. A handle with one way to go (a side,
|
||||
/// or a proportional corner) slides along it until one of them is on a target.
|
||||
pub fn snap_scale(&self, bx: &OrientedBox, handle: Handle, a: Affine, proportional: bool, from_center: bool, tol: f64) -> (Affine, Vec<Overlay>) {
|
||||
type Hit = Option<(f64, Point)>;
|
||||
// Where a snap lands: in line with a target's x and/or y, or on an anchor or centre.
|
||||
type Snap = (Hit, Hit, Option<(Point, Kind)>);
|
||||
let r = bx.rect;
|
||||
let origin = if from_center { r.center() } else { handle.opposite().pos(r) };
|
||||
let (d0, [sx, _, _, sy, _, _]) = (handle.pos(r) - origin, a.as_coeffs());
|
||||
@@ -405,12 +413,14 @@ impl Targets {
|
||||
ts.iter().map(|(t, from, _)| (*t, *from)).filter(|(t, _)| (t - v).abs() <= tol).min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs()))
|
||||
};
|
||||
let (mut nx, mut ny) = (sx, sy);
|
||||
let (mut hit_x, mut hit_y): (Hit, Hit) = (None, None);
|
||||
let mut snap: Snap = (None, None, None);
|
||||
if handle.is_corner() && !proportional {
|
||||
// The corner goes anywhere: it takes the target's x, its y, or both.
|
||||
// The corner goes anywhere: onto an anchor or centre, else it takes the target's x, its
|
||||
// y, or both.
|
||||
let p = to_doc * (a * handle.pos(r));
|
||||
let (hx, hy) = (nearest(p.x, &self.xs), nearest(p.y, &self.ys));
|
||||
for (tx, ty) in [(hx, hy), (hx, None), (None, hy)] {
|
||||
let on = self.nearest_point(p, tol).map(|(q, k)| (Some((q.x, q)), Some((q.y, q)), Some((q, k))));
|
||||
for (tx, ty, at) in on.into_iter().chain([(hx, hy, None), (hx, None, None), (None, hy, None)]) {
|
||||
if tx.is_none() && ty.is_none() {
|
||||
continue;
|
||||
}
|
||||
@@ -421,7 +431,7 @@ impl Targets {
|
||||
(cx, cy) = (if tx.is_some() { cx } else { sx }, if ty.is_some() { cy } else { sy });
|
||||
}
|
||||
if valid(cx, sx) && valid(cy, sy) {
|
||||
(nx, ny, hit_x, hit_y) = (cx, cy, tx, ty);
|
||||
(nx, ny, snap) = (cx, cy, (tx, ty, at));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -441,34 +451,44 @@ impl Targets {
|
||||
// How far the handle travels per unit of `s`: a snap may not drag it far from the
|
||||
// pointer, as it would for a target the side runs almost along.
|
||||
let travel = (e.0 * d0.x).hypot(e.1 * d0.y);
|
||||
let mut best: Option<(f64, f64, bool, (f64, Point))> = None;
|
||||
// The snap so far: its `s`, whether it only lines up (an anchor or centre comes first),
|
||||
// how far it moves the handle, and where it lands.
|
||||
let mut best: Option<(f64, bool, f64, Snap)> = None;
|
||||
let mut consider = |s: f64, in_line: bool, found: Snap| {
|
||||
let moved = (s - s0).abs() * travel;
|
||||
if valid(s, s0) && moved <= 2.0 * tol && best.as_ref().is_none_or(|k| (in_line, moved) < (k.1, k.2)) {
|
||||
best = Some((s, in_line, moved, found));
|
||||
}
|
||||
};
|
||||
for h in std::iter::once(handle).chain(ends.into_iter().flatten().copied()) {
|
||||
let v = h.pos(r) - origin;
|
||||
let base = to_doc * (origin + Vec2::new(c.0 * v.x, c.1 * v.y));
|
||||
let dir = (to_doc * Point::new(e.0 * v.x, e.1 * v.y)).to_vec2();
|
||||
let p = base + dir * s0;
|
||||
// An anchor or centre beside its way: it stops at the nearest point of the way.
|
||||
let l2 = dir.hypot2();
|
||||
if l2 > 1e-18
|
||||
&& let Some((q, k)) = self.nearest_point(p, tol)
|
||||
{
|
||||
consider((q - base).dot(dir) / l2, false, (None, None, Some((q, k))));
|
||||
}
|
||||
for (is_x, at, b, d, ts) in [(true, p.x, base.x, dir.x, &self.xs), (false, p.y, base.y, dir.y, &self.ys)] {
|
||||
if d.abs() <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let Some(hit) = nearest(at, ts) else { continue };
|
||||
let s = (hit.0 - b) / d;
|
||||
let moved = (s - s0).abs() * travel;
|
||||
if valid(s, s0) && moved <= 2.0 * tol && best.is_none_or(|k| moved < k.1) {
|
||||
best = Some((s, moved, is_x, hit));
|
||||
}
|
||||
consider((hit.0 - b) / d, true, if is_x { (Some(hit), None, None) } else { (None, Some(hit), None) });
|
||||
}
|
||||
}
|
||||
if let Some((s, _, is_x, hit)) = best {
|
||||
(nx, ny) = (c.0 + s * e.0, c.1 + s * e.1);
|
||||
if is_x {
|
||||
hit_x = Some(hit);
|
||||
} else {
|
||||
hit_y = Some(hit);
|
||||
}
|
||||
if let Some((s, _, _, found)) = best {
|
||||
(nx, ny, snap) = (c.0 + s * e.0, c.1 + s * e.1, found);
|
||||
}
|
||||
}
|
||||
let out = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(nx, ny) * Affine::translate(-origin.to_vec2());
|
||||
let (hit_x, hit_y, on) = snap;
|
||||
if let Some((q, k)) = on {
|
||||
return (out, self.label(q, k.label()).into_iter().collect());
|
||||
}
|
||||
// The lines run from the target's own point across the resized box, on the page.
|
||||
let nr = (to_doc * out).transform_rect_bbox(r);
|
||||
let mut ov = vec![];
|
||||
@@ -478,6 +498,9 @@ impl Targets {
|
||||
if let Some((y, from)) = hit_y {
|
||||
ov.extend(self.line(Point::new(from.x.min(nr.x0), y), Point::new(from.x.max(nr.x1), y)));
|
||||
}
|
||||
if hit_x.is_some() || hit_y.is_some() {
|
||||
ov.extend(self.label(to_doc * (out * handle.pos(r)), "align"));
|
||||
}
|
||||
(out, ov)
|
||||
}
|
||||
}
|
||||
@@ -612,13 +635,16 @@ mod tests {
|
||||
(a.transform_rect_bbox(r), ov)
|
||||
};
|
||||
// The bottom edge lands on the neighbour's bottom: same height.
|
||||
let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { .. })).count();
|
||||
let align = |ov: &[Overlay]| ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align"));
|
||||
let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 197.0), false, false);
|
||||
assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 200.0));
|
||||
assert_eq!(ov.len(), 1);
|
||||
assert_eq!(lines(&ov), 1);
|
||||
assert!(align(&ov), "{ov:?}");
|
||||
// A corner snaps each axis on its own: y to the neighbour, x stays free.
|
||||
let (nr, ov) = snap(Handle::BottomRight, Point::new(330.0, 202.0), false, false);
|
||||
assert_eq!(nr, Rect::new(250.0, 100.0, 330.0, 200.0));
|
||||
assert_eq!(ov.len(), 1);
|
||||
assert_eq!(lines(&ov), 1);
|
||||
// Proportional: the snapped axis carries the other one.
|
||||
let (nr, _) = snap(Handle::BottomRight, Point::new(340.0, 198.0), true, false);
|
||||
assert_eq!(nr, Rect::new(250.0, 100.0, 350.0, 200.0));
|
||||
@@ -634,6 +660,41 @@ mod tests {
|
||||
assert!(ov.is_empty());
|
||||
}
|
||||
|
||||
/// A resize handle lands on another object's anchor point (#482), not only in line with its
|
||||
/// edges and centre, and says so; a side handle stops where its way passes the anchor.
|
||||
#[test]
|
||||
fn scale_snap_lands_the_handle_on_anchors() {
|
||||
use crate::bbox::scale_for_drag;
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
// A triangle whose apex (320, 400) lines up with none of the edges or centres on x.
|
||||
let tri = vectorcraft_geom::PathData::single(SubPath::polyline(
|
||||
&[Point::new(300.0, 300.0), Point::new(380.0, 300.0), Point::new(320.0, 400.0)],
|
||||
true,
|
||||
));
|
||||
d.insert(Some(l), 1, Node::path(id, tri, Appearance::default_art())).unwrap();
|
||||
let t = Targets::collect(&d, &[], None);
|
||||
let snap = |r: Rect, h: Handle, p: Point, shift: bool| {
|
||||
let (a, ov) = t.snap_scale(&OrientedBox::aligned(r), h, scale_for_drag(r, h, p, shift, false), shift, false, 4.0);
|
||||
(a.transform_rect_bbox(r), ov)
|
||||
};
|
||||
let anchor = |ov: &[Overlay]| matches!(ov, [Overlay::Label { text, p, .. }] if text == "anchor" && *p == Point::new(320.0, 400.0));
|
||||
// A free corner takes the anchor's x and y.
|
||||
let (nr, ov) = snap(Rect::new(250.0, 350.0, 300.0, 390.0), Handle::BottomRight, Point::new(322.0, 398.0), false);
|
||||
assert_eq!(nr, Rect::new(250.0, 350.0, 320.0, 400.0));
|
||||
assert!(anchor(&ov), "{ov:?}");
|
||||
// The right side, at the apex's height: its x lands on the anchor.
|
||||
let (nr, ov) = snap(Rect::new(250.0, 380.0, 300.0, 420.0), Handle::Right, Point::new(318.0, 401.0), false);
|
||||
assert_eq!(nr, Rect::new(250.0, 380.0, 320.0, 420.0));
|
||||
assert!(anchor(&ov), "{ov:?}");
|
||||
// A proportional corner slides along its diagonal onto it.
|
||||
let (nr, ov) = snap(Rect::new(240.0, 320.0, 300.0, 380.0), Handle::BottomRight, Point::new(318.0, 398.0), true);
|
||||
assert_eq!(nr, Rect::new(240.0, 320.0, 320.0, 400.0));
|
||||
assert!(anchor(&ov), "{ov:?}");
|
||||
}
|
||||
|
||||
/// Preferences › Smart Guides › Display Options (#394): the colour, and Alignment Guides and
|
||||
/// Anchor/Path Labels off, change what shows and never where a point lands.
|
||||
#[test]
|
||||
|
||||
+28
-12
@@ -1,4 +1,5 @@
|
||||
//! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains; Smart
|
||||
//! The Selection tool (V): click/shift-click, marquee (Shift-drag toggles the objects it reaches:
|
||||
//! the selected ones are deselected, the others selected), move (Alt copies, Shift constrains; Smart
|
||||
//! Guides, or with them off View › Snap to Point, snap it),
|
||||
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
|
||||
//! drag a live rectangle's corner widget to round its corners (Alt-click cycles their kind,
|
||||
@@ -49,7 +50,8 @@ enum State {
|
||||
Marquee {
|
||||
start: Point,
|
||||
cur: Point,
|
||||
add: bool,
|
||||
/// Shift: the objects inside leave the selection if selected, else join it.
|
||||
toggle: bool,
|
||||
},
|
||||
/// Dragging a Live Corners widget.
|
||||
Corner(CornerDrag),
|
||||
@@ -256,7 +258,7 @@ impl Tool for SelectionTool {
|
||||
out
|
||||
}
|
||||
None => {
|
||||
self.state = State::Marquee { start: p, cur: p, add: m.shift };
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: m.shift };
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -299,8 +301,8 @@ impl Tool for SelectionTool {
|
||||
self.measure = cx.transform_tools_guides.then(|| (p, format!("{:.1}°", -deg)));
|
||||
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))]
|
||||
}
|
||||
(PointerKind::Drag, State::Marquee { start, add, .. }) => {
|
||||
self.state = State::Marquee { start, cur: p, add };
|
||||
(PointerKind::Drag, State::Marquee { start, toggle, .. }) => {
|
||||
self.state = State::Marquee { start, cur: p, toggle };
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Drag, State::Corner(mut c)) => {
|
||||
@@ -339,18 +341,14 @@ impl Tool for SelectionTool {
|
||||
self.targets = None;
|
||||
vec![Action::Commit]
|
||||
}
|
||||
(PointerKind::Up, State::Marquee { start, add, .. }) => {
|
||||
(PointerKind::Up, State::Marquee { start, toggle, .. }) => {
|
||||
self.state = State::Idle;
|
||||
let r = Rect::from_points(start, p);
|
||||
if r.width() < Self::drag_threshold(cx) && r.height() < Self::drag_threshold(cx) {
|
||||
return if add { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||||
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||||
}
|
||||
let ids: Vec<NodeId> = marquee(cx.doc, r, cx.isolation, false);
|
||||
if add {
|
||||
vec![Action::Exec("select.add".into(), json!({ "ids": json_ids(&ids) }))]
|
||||
} else {
|
||||
vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))]
|
||||
}
|
||||
vec![Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) }))]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
@@ -556,6 +554,24 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
||||
}
|
||||
|
||||
/// Shift-drag a marquee: the objects it reaches toggle (#483), so selected ones can be
|
||||
/// taken out of the selection; a Shift-click on empty canvas leaves the selection alone.
|
||||
#[test]
|
||||
fn shift_marquee_toggles() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(shift));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0).with_mods(shift));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"ids": [id.0]}))]);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(shift));
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0).with_mods(shift)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_drag_scales() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
//! at release makes a copy: Alt-drag). Alt-click (the release of a click made with Alt) sets the
|
||||
//! point and opens the tool's dialog; double-click or Return opens the dialog too. A clicked or
|
||||
//! Alt-clicked point snaps to the nearest anchor or centre (Snap to Point / Smart Guides), labelled
|
||||
//! while hovering, so transforms pivot exactly on a corner.
|
||||
//! while hovering, so transforms pivot exactly on a corner. A drag starts from the point it grabbed
|
||||
//! snapped the same way, and the pointer snaps as a drawn point does (Smart Guides: the other
|
||||
//! objects' anchors, centres and edges; or the grid, pixels, Snap to Point), so a corner dragged
|
||||
//! onto another object's anchor scales exactly onto it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
@@ -13,7 +16,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::target_overlays;
|
||||
use crate::bbox::rotate_for_drag;
|
||||
use crate::guides::snap_pick;
|
||||
use crate::guides::{PointSnap, Targets, snap_pick};
|
||||
use crate::select::{matrix_json, selection_bounds, selection_box};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
@@ -57,7 +60,8 @@ impl TransformKind {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Drag {
|
||||
start: Point,
|
||||
/// The pressed point snapped to an anchor or centre (becomes the reference point on a click).
|
||||
/// The pressed point snapped to an anchor or centre: the reference point on a click, where a
|
||||
/// drag starts from.
|
||||
pick: Point,
|
||||
origin: Point,
|
||||
began: bool,
|
||||
@@ -73,8 +77,10 @@ pub struct TransformTool {
|
||||
origin: Option<(Point, Vec<NodeId>)>,
|
||||
drag: Option<Drag>,
|
||||
measure: Option<(Point, String)>,
|
||||
/// Snap feedback while hovering ("anchor" / "center" label).
|
||||
/// Snap feedback while hovering ("anchor" / "center" label), and for the dragged pointer.
|
||||
hover: Vec<Overlay>,
|
||||
/// What the dragged pointer snaps to, gathered when the drag begins.
|
||||
snap: Option<PointSnap>,
|
||||
}
|
||||
|
||||
fn about(o: Point, a: Affine) -> Affine {
|
||||
@@ -88,7 +94,7 @@ pub fn reflect_matrix(o: Point, theta: f64) -> Affine {
|
||||
|
||||
impl TransformTool {
|
||||
pub fn new(kind: TransformKind) -> Self {
|
||||
Self { kind, origin: None, drag: None, measure: None, hover: vec![] }
|
||||
Self { kind, origin: None, drag: None, measure: None, hover: vec![], snap: None }
|
||||
}
|
||||
|
||||
/// The effective reference point: the custom one if it belongs to the current selection,
|
||||
@@ -150,6 +156,14 @@ impl TransformTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends the drag, its snapping and readouts with it.
|
||||
fn end_drag(&mut self) -> Option<Drag> {
|
||||
self.measure = None;
|
||||
self.snap = None;
|
||||
self.hover.clear();
|
||||
self.drag.take()
|
||||
}
|
||||
|
||||
fn dialog(&self, o: Point) -> Action {
|
||||
Action::Dialog(self.kind.id().into(), self.kind.dialog_fields(o))
|
||||
}
|
||||
@@ -185,9 +199,13 @@ impl Tool for TransformTool {
|
||||
return out;
|
||||
}
|
||||
d.began = true;
|
||||
// The selection moves, so only the other objects are targets.
|
||||
self.snap = Some(PointSnap::new(cx, || Targets::collect(cx.doc, &cx.selection.objects, None)));
|
||||
out.push(Action::Begin(self.kind.label().into()));
|
||||
}
|
||||
let (m, text) = self.matrix_for(d.origin, d.start, p, ev.mods.shift, d.bounds);
|
||||
let (p, guides) = self.snap.as_ref().map_or((p, vec![]), |s| s.snap(cx, p));
|
||||
self.hover = guides;
|
||||
let (m, text) = self.matrix_for(d.origin, d.pick, p, ev.mods.shift, d.bounds);
|
||||
d.last = m;
|
||||
self.drag = Some(d);
|
||||
self.measure = Some((p, text));
|
||||
@@ -195,8 +213,7 @@ impl Tool for TransformTool {
|
||||
out
|
||||
}
|
||||
PointerKind::Up => {
|
||||
let Some(d) = self.drag.take() else { return vec![] };
|
||||
self.measure = None;
|
||||
let Some(d) = self.end_drag() else { return vec![] };
|
||||
if d.began {
|
||||
// The Alt state at release decides whether the result is a copy.
|
||||
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(d.last), "copy": ev.mods.alt })), Action::Commit]
|
||||
@@ -207,14 +224,16 @@ impl Tool for TransformTool {
|
||||
}
|
||||
}
|
||||
PointerKind::DoubleClick => {
|
||||
self.drag = None;
|
||||
self.end_drag();
|
||||
match self.reference_point(cx) {
|
||||
Some(o) => vec![self.dialog(o)],
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
PointerKind::Move => {
|
||||
self.hover = if self.drag.is_none() && !cx.selection.is_empty() { snap_pick(cx, p).1 } else { vec![] };
|
||||
if self.drag.is_none() {
|
||||
self.hover = if cx.selection.is_empty() { vec![] } else { snap_pick(cx, p).1 };
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -223,8 +242,7 @@ impl Tool for TransformTool {
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
match key {
|
||||
ToolKey::Escape if self.busy() => {
|
||||
self.drag = None;
|
||||
self.measure = None;
|
||||
self.end_drag();
|
||||
vec![Action::Cancel]
|
||||
}
|
||||
ToolKey::Enter => self.reference_point(cx).map(|o| vec![self.dialog(o)]).unwrap_or_default(),
|
||||
@@ -278,7 +296,7 @@ impl Tool for TransformTool {
|
||||
}
|
||||
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
if self.drag.take().is_some_and(|d| d.began) { vec![Action::Cancel] } else { vec![] }
|
||||
if self.end_drag().is_some_and(|d| d.began) { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,6 +446,43 @@ mod tests {
|
||||
assert!(matches!(&a[0], Action::Dialog(_, v) if v["origin"] == json!([102.0, 98.0])));
|
||||
}
|
||||
|
||||
/// Smart Guides while a transform tool drags (#482): the drag starts from the selection's
|
||||
/// anchor it grabbed and the pointer lands on another object's anchor, so the scale is exact.
|
||||
#[test]
|
||||
fn scale_drag_snaps_from_an_anchor_onto_another_objects_anchor() {
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let other = d.alloc_id();
|
||||
d.insert(
|
||||
Some(l),
|
||||
1,
|
||||
Node::path(other, vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 260.0, 340.0, 300.0)), Appearance::default_art()),
|
||||
)
|
||||
.unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let mut c = cx(&d, &s, &p);
|
||||
let mut t = TransformTool::new(TransformKind::Scale);
|
||||
// The reference point on the top-left corner, then a drag from near the bottom-right one
|
||||
// to near the other rect's top-left corner.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0));
|
||||
t.pointer(&c, &ev(PointerKind::Up, 100.0, 100.0));
|
||||
t.pointer(&c, &ev(PointerKind::Down, 199.0, 198.0));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 302.0, 262.0));
|
||||
let m = matrix(&a[1]);
|
||||
assert!((m * Point::new(200.0, 200.0)).distance(Point::new(300.0, 260.0)) < 1e-9, "{m:?}");
|
||||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "anchor" && *p == Point::new(300.0, 260.0))));
|
||||
t.pointer(&c, &ev(PointerKind::Up, 302.0, 262.0));
|
||||
assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Label { .. })));
|
||||
// Smart Guides and Snap to Point off: the pointer is where it is.
|
||||
(c.smart_guides, c.snap_to_point) = (false, false);
|
||||
t.pointer(&c, &ev(PointerKind::Down, 200.0, 200.0));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 302.0, 262.0));
|
||||
assert!((matrix(&a[1]) * Point::new(200.0, 200.0)).distance(Point::new(302.0, 262.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflect_vertical_axis_mirrors_x() {
|
||||
let t = TransformTool::new(TransformKind::Reflect);
|
||||
|
||||
@@ -115,25 +115,15 @@ impl Tool for LassoTool {
|
||||
}
|
||||
});
|
||||
hits.retain(|(id, _)| cx.doc.is_editable(*id) && cx.doc.is_visible(*id));
|
||||
if sub {
|
||||
// Current anchor selection minus the lassoed anchors.
|
||||
let mut items = vec![];
|
||||
for id in &cx.selection.objects {
|
||||
let Some(path) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
|
||||
let cur: BTreeSet<AnchorRef> = match cx.selection.partial(*id) {
|
||||
Some(s) => s.clone(),
|
||||
None => path.anchors().map(|(s, i, _)| (s, i)).collect(),
|
||||
};
|
||||
let remove = hits.iter().find(|(h, _)| h == id).map(|(_, v)| v.clone()).unwrap_or_default();
|
||||
let rest: BTreeSet<AnchorRef> = cur.difference(&remove).copied().collect();
|
||||
if !rest.is_empty() {
|
||||
items.push(json!({ "id": id.0, "anchors": anchors_json(&rest) }));
|
||||
}
|
||||
}
|
||||
return vec![Action::Exec("select.anchorsMany".into(), json!({ "items": items, "add": false }))];
|
||||
}
|
||||
let items: Vec<Value> = hits.iter().map(|(id, v)| json!({ "id": id.0, "anchors": anchors_json(v) })).collect();
|
||||
vec![Action::Exec("select.anchorsMany".into(), json!({ "items": items, "add": add }))]
|
||||
let mode = if sub {
|
||||
"subtract"
|
||||
} else if add {
|
||||
"add"
|
||||
} else {
|
||||
"set"
|
||||
};
|
||||
vec![Action::Exec("select.anchorsMany".into(), json!({ "items": items, "mode": mode }))]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
@@ -198,7 +188,7 @@ mod tests {
|
||||
assert_eq!(c, "select.anchorsMany");
|
||||
assert_eq!(v["items"][0]["id"], id.0);
|
||||
assert_eq!(v["items"][0]["anchors"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(v["add"], false);
|
||||
assert_eq!(v["mode"], "set");
|
||||
assert!(point_in_polygon(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], Point::new(2.0, 2.0)));
|
||||
}
|
||||
}
|
||||
@@ -225,8 +225,7 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
if d.fields.contains_key("gamma") {
|
||||
widgets::label_row(ui, tl!("Gamma"), LABEL_W, |ui| {
|
||||
let mut g = d.f64("gamma", 1.0);
|
||||
if ui.add(egui::DragValue::new(&mut g).speed(0.01).range(0.1..=10.0).max_decimals(2)).changed() {
|
||||
if let Some(g) = widgets::range_field(ui, "gamma", d.f64("gamma", 1.0), 0.1..=10.0, "", 2, 60.0) {
|
||||
d.fields.insert("gamma".into(), json!(g));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -238,9 +238,7 @@ pub(super) fn param_fields(
|
||||
}
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
let mut x = n.as_f64().unwrap_or(0.0);
|
||||
let speed = if x.abs() > 20.0 { 1.0 } else { 0.1 };
|
||||
if ui.add(egui::DragValue::new(&mut x).speed(speed).max_decimals(2)).changed() {
|
||||
if let Some(x) = crate::widgets::plain_field(ui, ("fx-num", &k), n.as_f64().unwrap_or(0.0), "", 3, 140.0) {
|
||||
d.fields.insert(k, json!(x));
|
||||
changed = true;
|
||||
}
|
||||
@@ -288,13 +286,12 @@ pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String,
|
||||
let cur = d.fields.get(k).cloned().unwrap_or_else(|| spec.default_value());
|
||||
let new = match spec {
|
||||
ParamSpec::Number { min, max, .. } => {
|
||||
let mut x = cur.as_f64().unwrap_or(*min).clamp(*min, *max);
|
||||
let speed = ((max - min) / 200.0).clamp(0.01, 10.0);
|
||||
ui.add(egui::DragValue::new(&mut x).range(*min..=*max).speed(speed).max_decimals(2)).changed().then(|| json!(x))
|
||||
let x = cur.as_f64().unwrap_or(*min);
|
||||
crate::widgets::range_field(ui, ("plugin-num", k), x, *min..=*max, "", 2, 100.0).map(|x| json!(x))
|
||||
}
|
||||
ParamSpec::Int { min, max, .. } => {
|
||||
let mut x = cur.as_i64().unwrap_or(*min).clamp(*min, *max);
|
||||
ui.add(egui::DragValue::new(&mut x).range(*min..=*max).speed(0.2)).changed().then(|| json!(x))
|
||||
let x = cur.as_i64().unwrap_or(*min) as f64;
|
||||
crate::widgets::range_field(ui, ("plugin-int", k), x, *min as f64..=*max as f64, "", 0, 100.0).map(|x| json!(x as i64))
|
||||
}
|
||||
ParamSpec::Bool { .. } => {
|
||||
let mut b = cur.as_bool().unwrap_or(false);
|
||||
|
||||
@@ -178,9 +178,10 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
toggle(ui, d, "preview", tl!("Preview"), true);
|
||||
ui.horizontal(|ui| {
|
||||
toggle(ui, d, "dimImages", tl!("Dim Images to:"), true);
|
||||
let mut v = d.f64("dimPercent", 50.0);
|
||||
if ui.add_enabled(d.bool("dimImages"), egui::DragValue::new(&mut v).range(0.0..=100.0).suffix("%")).changed() {
|
||||
d.fields.insert("dimPercent".into(), json!(v.round()));
|
||||
let v = d.f64("dimPercent", 50.0);
|
||||
let dim = d.bool("dimImages");
|
||||
if let Some(v) = ui.add_enabled_ui(dim, |ui| crate::widgets::range_field(ui, "dimPercent", v, 0.0..=100.0, "%", 0, 52.0)).inner {
|
||||
d.fields.insert("dimPercent".into(), json!(v));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,9 +51,10 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
if let Some(i) = widgets::dropdown(ui, "layers-row-size", shown, &labels, 110.0) {
|
||||
d.fields.insert("rowSize".into(), json!(SIZES.get(i).map_or("other", |s| s.0)));
|
||||
}
|
||||
let mut v = d.f64("otherSize", f64::from(ROW_MEDIUM));
|
||||
if ui.add_enabled(d.str("rowSize") == "other", egui::DragValue::new(&mut v).range(12.0..=100.0).suffix(" pt")).changed() {
|
||||
d.fields.insert("otherSize".into(), json!(v.round()));
|
||||
let v = d.f64("otherSize", f64::from(ROW_MEDIUM));
|
||||
let other = d.str("rowSize") == "other";
|
||||
if let Some(v) = ui.add_enabled_ui(other, |ui| widgets::range_field(ui, "otherSize", v, 12.0..=100.0, " pt", 0, 60.0)).inner {
|
||||
d.fields.insert("otherSize".into(), json!(v));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
|
||||
@@ -254,9 +254,9 @@ fn method_row(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &
|
||||
choice(ui, d, "method", &Method::ALL.map(Method::id), &Method::ALL.map(Method::label));
|
||||
if Method::from_id(&d.str("method")) == Some(Method::Progressive) {
|
||||
label(ui, tl!("Scans:"));
|
||||
let mut scans = d.f64("scans", 3.0).round().clamp(*jpeg::SCANS.start() as f64, *jpeg::SCANS.end() as f64) as u8;
|
||||
if ui.add(egui::DragValue::new(&mut scans).range(jpeg::SCANS)).changed() {
|
||||
d.fields.insert("scans".into(), json!(scans));
|
||||
let range = f64::from(*jpeg::SCANS.start())..=f64::from(*jpeg::SCANS.end());
|
||||
if let Some(scans) = widgets::range_field(ui, "scans", d.f64("scans", 3.0), range, "", 0, 44.0) {
|
||||
d.fields.insert("scans".into(), json!(scans as u8));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -277,8 +277,8 @@ fn palette_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui,
|
||||
n = COLOR_COUNTS.get(i).and_then(|c| c.parse().ok()).unwrap_or(n);
|
||||
d.fields.insert("colors".into(), json!(n));
|
||||
}
|
||||
if ui.add(egui::DragValue::new(&mut n).range(2..=256)).changed() {
|
||||
d.fields.insert("colors".into(), json!(n));
|
||||
if let Some(n) = widgets::range_field(ui, "colors", f64::from(n), 2.0..=256.0, "", 0, 52.0) {
|
||||
d.fields.insert("colors".into(), json!(n as u16));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
@@ -286,11 +286,10 @@ fn palette_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui,
|
||||
label(ui, tl!("Dither:"));
|
||||
ui.horizontal(|ui| {
|
||||
choice(ui, d, "dither", &Dither::ALL.map(Dither::id), &Dither::ALL.map(Dither::label));
|
||||
if Dither::from_id(&d.str("dither")).is_some_and(|x| x != Dither::None) {
|
||||
let mut amount = d.f64("ditherAmount", 100.0).round().clamp(0.0, 100.0) as u8;
|
||||
if ui.add(egui::DragValue::new(&mut amount).range(0..=100).suffix("%")).changed() {
|
||||
d.fields.insert("ditherAmount".into(), json!(amount));
|
||||
}
|
||||
if Dither::from_id(&d.str("dither")).is_some_and(|x| x != Dither::None)
|
||||
&& let Some(amount) = widgets::range_field(ui, "ditherAmount", d.f64("ditherAmount", 100.0), 0.0..=100.0, "%", 0, 52.0)
|
||||
{
|
||||
d.fields.insert("ditherAmount".into(), json!(amount as u8));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
|
||||
@@ -646,8 +646,8 @@ fn colors_row(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
n = c.parse().unwrap_or(n);
|
||||
d.fields.insert("colors".into(), json!(n));
|
||||
}
|
||||
if ui.add(egui::DragValue::new(&mut n).range(2..=256)).changed() {
|
||||
d.fields.insert("colors".into(), json!(n));
|
||||
if let Some(n) = widgets::range_field(ui, "sfw-colors-n", f64::from(n), 2.0..=256.0, "", 0, 52.0) {
|
||||
d.fields.insert("colors".into(), json!(n as u16));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,11 +169,11 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma
|
||||
}
|
||||
} else {
|
||||
let integer = o.ty == "integer";
|
||||
let mut x = value.as_f64().unwrap_or(0.0);
|
||||
let drag =
|
||||
egui::DragValue::new(&mut x).range(0.0..=f64::MAX).speed(if integer { 1.0 } else { 0.1 }).max_decimals(if integer { 0 } else { 2 });
|
||||
if ui.add(drag).on_hover_text(o.description).changed() {
|
||||
d.fields.insert(o.name.into(), if integer { json!(x.round() as i64) } else { json!(x) });
|
||||
let x = value.as_f64().unwrap_or(0.0);
|
||||
let field = ui.scope(|ui| widgets::range_field(ui, ("save-option", o.name), x, 0.0..=f64::MAX, "", if integer { 0 } else { 2 }, 80.0));
|
||||
field.response.on_hover_text(o.description);
|
||||
if let Some(x) = field.inner {
|
||||
d.fields.insert(o.name.into(), if integer { json!(x as i64) } else { json!(x) });
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
@@ -235,13 +235,12 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
|
||||
PrefKind::Num { min, max, unit } => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
let mut x = v.as_f64().unwrap_or(min);
|
||||
let r = if sp.key == "uiScaling" {
|
||||
ui.add(egui::Slider::new(&mut x, min..=max).step_by(0.05).text(tl!("Smaller ↔ Larger")))
|
||||
let new = if sp.key == "uiScaling" {
|
||||
ui.add(egui::Slider::new(&mut x, min..=max).step_by(0.05).text(tl!("Smaller ↔ Larger"))).changed().then_some(x)
|
||||
} else {
|
||||
let speed = if max - min > 100.0 { 0.5 } else { 0.05 };
|
||||
ui.add(egui::DragValue::new(&mut x).range(min..=max).speed(speed).max_decimals(3).suffix(format!(" {unit}")))
|
||||
widgets::range_field(ui, sp.key, x, min..=max, &format!(" {unit}"), 3, 110.0)
|
||||
};
|
||||
if r.changed() {
|
||||
if let Some(x) = new {
|
||||
d.fields.insert(sp.key.into(), json!(x));
|
||||
}
|
||||
});
|
||||
@@ -259,12 +258,12 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
|
||||
PrefKind::Int { min, max } => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
let mut x = v.as_i64().unwrap_or(min);
|
||||
let r = if sp.key == "anchorSize" {
|
||||
ui.add(egui::Slider::new(&mut x, min..=max).show_value(false).text(tl!("Size")))
|
||||
let new = if sp.key == "anchorSize" {
|
||||
ui.add(egui::Slider::new(&mut x, min..=max).show_value(false).text(tl!("Size"))).changed().then_some(x)
|
||||
} else {
|
||||
ui.add(egui::DragValue::new(&mut x).range(min..=max).speed(0.2))
|
||||
widgets::range_field(ui, sp.key, x as f64, min as f64..=max as f64, "", 0, 110.0).map(|x| x as i64)
|
||||
};
|
||||
if r.changed() {
|
||||
if let Some(x) = new {
|
||||
d.fields.insert(sp.key.into(), json!(x));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -180,3 +180,90 @@ fn arrow_keys_step_plain_fields() {
|
||||
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::COMMAND)], &count), None);
|
||||
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &count), Some(4.0));
|
||||
}
|
||||
|
||||
/// A wheel turn of `dy` (`unit`s, up positive) with `modifiers`, the pointer at `at`.
|
||||
fn wheel(at: Pos2, unit: egui::MouseWheelUnit, dy: f32, modifiers: Modifiers) -> Vec<Event> {
|
||||
vec![Event::PointerMoved(at), Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers }]
|
||||
}
|
||||
|
||||
/// The mouse wheel over a focused numeric field steps it as ↑/↓ do (#485): a notch a step, Shift
|
||||
/// ten, Ctrl/Cmd a tenth; a trackpad's points add up to notches. Unfocused, or with the pointer
|
||||
/// elsewhere, the wheel isn't the field's.
|
||||
#[test]
|
||||
fn the_wheel_steps_a_focused_field_under_the_pointer() {
|
||||
use egui::MouseWheelUnit::{Line, Point};
|
||||
let mut f = Field::new();
|
||||
let u = Unit::Millimeters;
|
||||
let mm = Cell::new(4.0);
|
||||
let draw = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(u.to_pt(mm.get())), u, 80.0).inspect(|&v| mm.set(u.from_pt(v)));
|
||||
f.frame(vec![], &draw);
|
||||
let at = f.rect.get().center();
|
||||
assert_eq!(f.frame(wheel(at, Line, 1.0, Modifiers::NONE), &draw), None, "unfocused");
|
||||
f.frame(f.click(1), &draw);
|
||||
f.frame(vec![], &draw);
|
||||
for (dy, mods, want) in
|
||||
[(1.0, Modifiers::NONE, 5.0), (2.0, Modifiers::SHIFT, 25.0), (-1.0, Modifiers::COMMAND, 24.9), (-1.0, Modifiers::NONE, 23.9)]
|
||||
{
|
||||
assert!(f.frame(wheel(at, Line, dy, mods), &draw).is_some(), "{dy} {mods:?}");
|
||||
assert!((mm.get() - want).abs() < 1e-6, "{dy} {mods:?}: {}", mm.get());
|
||||
}
|
||||
assert_eq!(f.selection(), Some((0, "23.9 mm".len())), "the new value is selected");
|
||||
// Points: a step for each line's worth, what is left carried to the next turn.
|
||||
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
|
||||
assert_eq!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw), None);
|
||||
assert!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw).is_some());
|
||||
assert!((mm.get() - 24.9).abs() < 1e-6, "{}", mm.get());
|
||||
assert_eq!(f.frame(wheel(Pos2::new(390.0, 190.0), Line, 1.0, Modifiers::NONE), &draw), None, "the pointer elsewhere");
|
||||
// Plain fields (degrees, percent, counts) too.
|
||||
let mut f = Field::new();
|
||||
let count = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", 3.0, "", 0, 80.0);
|
||||
f.frame(vec![], &count);
|
||||
f.frame(f.click(1), &count);
|
||||
f.frame(vec![], &count);
|
||||
assert_eq!(f.frame(wheel(f.rect.get().center(), Line, -1.0, Modifiers::NONE), &count), Some(2.0));
|
||||
}
|
||||
|
||||
/// The wheel over a focused field steps it and leaves the panel where it is; over an unfocused one
|
||||
/// it scrolls the panel as before.
|
||||
#[test]
|
||||
fn the_wheel_scrolls_the_panel_unless_over_the_focused_field() {
|
||||
for focus in [false, true] {
|
||||
let mut f = Field::new();
|
||||
let offset = Cell::new(0.0);
|
||||
let draw = |ui: &mut egui::Ui| {
|
||||
let out = egui::ScrollArea::vertical().max_height(60.0).show(ui, |ui| {
|
||||
let v = widgets::plain_field(ui, "f", 10.0, "", 0, 80.0);
|
||||
ui.add_space(600.0);
|
||||
v
|
||||
});
|
||||
offset.set(out.state.offset.y);
|
||||
out.inner
|
||||
};
|
||||
f.frame(vec![], &draw);
|
||||
let at = Pos2::new(20.0, 12.0);
|
||||
if focus {
|
||||
f.frame(
|
||||
f.click(1)
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
if let Event::PointerButton { button, pressed, modifiers, .. } = e {
|
||||
Event::PointerButton { pos: at, button, pressed, modifiers }
|
||||
} else {
|
||||
Event::PointerMoved(at)
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
&draw,
|
||||
);
|
||||
}
|
||||
let stepped = f.frame(wheel(at, egui::MouseWheelUnit::Line, -2.0, Modifiers::NONE), &draw);
|
||||
for _ in 0..20 {
|
||||
f.frame(vec![], &draw);
|
||||
}
|
||||
if focus {
|
||||
assert_eq!((stepped, offset.get()), (Some(8.0), 0.0));
|
||||
} else {
|
||||
assert!(stepped.is_none() && offset.get() > 0.0, "{stepped:?} {}", offset.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,8 +181,8 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
||||
})
|
||||
.inner;
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
// ↑/↓ step in the field's unit; a drag on its label scrubs it.
|
||||
let stepped = step_with_arrows(ui, &resp, &mut buf, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.format(unit.to_pt(v)));
|
||||
// ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it.
|
||||
let stepped = step_value(ui, &resp, &mut buf, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.format(unit.to_pt(v)));
|
||||
let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS);
|
||||
let commit = resp.lost_focus() && buf != shown;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
@@ -198,26 +198,58 @@ pub(crate) fn round_to(v: f64, decimals: i32) -> f64 {
|
||||
(v * scale).round() / scale
|
||||
}
|
||||
|
||||
/// ↑/↓ in focused numeric field `resp` step the number `buf` shows (`read` parses it, `show`
|
||||
/// formats it) by one (Shift: ten, Ctrl/Cmd: a tenth), applied at once as in Illustrator's panels
|
||||
/// and dialogs. The new text replaces `buf`, all selected so typing replaces it. Returns the new
|
||||
/// number when a step was taken.
|
||||
fn step_with_arrows(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) -> Option<f64>, show: impl Fn(f64) -> String) -> Option<f64> {
|
||||
/// One step of a numeric field with each modifier, most specific first (a plain pattern would also
|
||||
/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one.
|
||||
const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)];
|
||||
|
||||
/// ↑/↓ in focused numeric field `resp`, or the mouse wheel turned over it, step the number `buf`
|
||||
/// shows (`read` parses it, `show` formats it) by one per press or wheel notch ([`STEPS`]: Shift
|
||||
/// ten, Ctrl/Cmd a tenth), applied at once as in Illustrator's panels and dialogs. The new text
|
||||
/// replaces `buf`, all selected so typing replaces it. Returns the new number when a step was
|
||||
/// taken. While the pointer is over the focused field the wheel is the field's (the panel under it
|
||||
/// doesn't scroll); an unfocused field leaves the wheel to the panel and the canvas.
|
||||
fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) -> Option<f64>, show: impl Fn(f64) -> String) -> Option<f64> {
|
||||
// Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the
|
||||
// window to report keyboard focus.
|
||||
if !ui.memory(|m| m.has_focus(resp.id)) {
|
||||
return None;
|
||||
}
|
||||
use egui::{Key, Modifiers};
|
||||
use egui::{Event, Key, MouseWheelUnit};
|
||||
let wheel = resp.contains_pointer();
|
||||
// Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is
|
||||
// left of a notch carries over to the next turn.
|
||||
let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel"));
|
||||
let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 };
|
||||
let mut steps = 0.0;
|
||||
ui.input_mut(|i| {
|
||||
// Most specific first: a plain pattern would also take Shift+↑.
|
||||
for (mods, size) in [(Modifiers::SHIFT, 10.0), (Modifiers::COMMAND, 0.1), (Modifiers::NONE, 1.0)] {
|
||||
for (mods, size) in STEPS {
|
||||
let up = i.count_and_consume_key(mods, Key::ArrowUp) as f64;
|
||||
let down = i.count_and_consume_key(mods, Key::ArrowDown) as f64;
|
||||
steps += size * (up - down);
|
||||
}
|
||||
if !wheel {
|
||||
return;
|
||||
}
|
||||
i.events.retain(|e| {
|
||||
let Event::MouseWheel { unit, delta, modifiers, .. } = e else { return true };
|
||||
let Some((_, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true };
|
||||
// Shift may turn the wheel sideways (the Mac does).
|
||||
let d = if modifiers.shift { delta.x + delta.y } else { delta.y };
|
||||
notches += match unit {
|
||||
MouseWheelUnit::Point => d / line.max(1.0),
|
||||
MouseWheelUnit::Line | MouseWheelUnit::Page => d,
|
||||
};
|
||||
let whole = notches.trunc();
|
||||
notches -= whole;
|
||||
steps += size * f64::from(whole);
|
||||
false
|
||||
});
|
||||
// The rest of a turn egui spreads over the next frames scrolls nothing either.
|
||||
i.smooth_scroll_delta = egui::Vec2::ZERO;
|
||||
});
|
||||
if wheel {
|
||||
ui.data_mut(|d| d.insert_temp(notch_id, notches));
|
||||
}
|
||||
if steps == 0.0 {
|
||||
return None;
|
||||
}
|
||||
@@ -331,6 +363,22 @@ pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, valu
|
||||
mixed_field(ui, id, Some(value), suffix, decimals, width)
|
||||
}
|
||||
|
||||
/// [`plain_field`] for a number kept within `range` (the values of dialogs and preferences): what is
|
||||
/// typed, stepped or scrubbed is clamped into it and rounded to `decimals` places (0: a count).
|
||||
pub fn range_field(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash + std::fmt::Debug,
|
||||
value: f64,
|
||||
range: std::ops::RangeInclusive<f64>,
|
||||
suffix: &str,
|
||||
decimals: usize,
|
||||
width: f32,
|
||||
) -> Option<f64> {
|
||||
let (lo, hi) = (*range.start(), *range.end());
|
||||
let places = decimals.min(6) as i32;
|
||||
plain_field(ui, id, value.clamp(lo, hi), suffix, decimals, width).map(|v| round_to(v, places).clamp(lo, hi))
|
||||
}
|
||||
|
||||
/// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field.
|
||||
pub fn mixed_field(
|
||||
ui: &mut Ui,
|
||||
@@ -355,10 +403,10 @@ pub fn mixed_field(
|
||||
let shown = value.map(show).unwrap_or_default();
|
||||
let (mut buf, resp, rect) = recessed_text(ui, id, &shown, width, 1);
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
// ↑/↓ step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on its label
|
||||
// scrubs it.
|
||||
// ↑/↓ and the wheel step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on
|
||||
// its label scrubs it.
|
||||
let decimals = decimals.min(6) as i32;
|
||||
let stepped = step_with_arrows(ui, &resp, &mut buf, read, show).map(|v| round_to(v, decimals));
|
||||
let stepped = step_value(ui, &resp, &mut buf, read, show).map(|v| round_to(v, decimals));
|
||||
let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals);
|
||||
if resp.lost_focus() && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) }
|
||||
}
|
||||
@@ -1265,13 +1313,15 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
||||
});
|
||||
let resp = framed.inner;
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
// ↑/↓ and the wheel step a value (an empty field stays empty); a drag on its label scrubs it.
|
||||
let stepped = step_value(ui, &resp, &mut buf, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.number(unit.to_pt(v)));
|
||||
let scrubbed = scrub::field(ui, id, framed.response.rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS);
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
if resp.lost_focus() && buf != shown {
|
||||
let s = buf.trim();
|
||||
if s.is_empty() { Some(None) } else { unit.parse(s).map(Some) }
|
||||
} else {
|
||||
scrubbed.map(|v| Some(unit.to_pt(v)))
|
||||
stepped.or(scrubbed).map(|v| Some(unit.to_pt(v)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser
|
||||
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
|
||||
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
|
||||
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways |
|
||||
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it |
|
||||
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
|
||||
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog |
|
||||
| `ui.screenshot` | `{path?}` | capture the window (PNG). Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
|
||||
|
||||
+20
@@ -189,6 +189,16 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
|
||||
| `undo` / `redo` | `{}` | |
|
||||
|
||||
With `mods.shift`, a marquee dragged with the Selection tool toggles the objects it reaches (the selected ones leave
|
||||
the selection, the others join it, the rest stays), and one dragged with Direct or Group Selection toggles the anchors
|
||||
inside it (a path left with none leaves the selection, one with all of them is selected whole). The Lasso adds anchors
|
||||
with `mods.shift` and takes them away with `mods.alt`. They run `select.toggle {ids}` and `select.anchorsMany {items,
|
||||
mode: "set"|"add"|"toggle"|"subtract"}`:
|
||||
|
||||
```json
|
||||
{"name":"pointer_gesture","arguments":{"tool":"selection","mods":{"shift":true},"events":[{"kind":"down","x":180,"y":80},{"kind":"drag","x":300,"y":200},{"kind":"up","x":380,"y":200}]}}
|
||||
```
|
||||
|
||||
Appearance stacks: an object can carry several fills and strokes (`appearance.addFill`, `appearance.addStroke`), indexed
|
||||
in paint order (0 is painted first, the bottom row of the Appearance panel). `paint.setFill`, `paint.setStroke`,
|
||||
`stroke.set`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and `transparency.set` take
|
||||
@@ -1202,6 +1212,16 @@ point lands:
|
||||
a dragged selection, a bounding-box handle, a ruler guide or an artboard. With Smart Guides off, Snap to Point uses
|
||||
`snapToPointTolerance` instead.
|
||||
|
||||
With Smart Guides on, a bounding-box handle dragged with the Selection or Free Transform tool (`mods.shift`
|
||||
proportional, `mods.alt` from the centre) lands on another object's anchor or centre (labelled "anchor" or
|
||||
"center"), else its corners and side line up with the edges and centres of the other objects and the artboards (a
|
||||
line and "align"). The Rotate, Scale, Shear and Reflect tools start a drag from the anchor or centre they grab and
|
||||
snap the pointer as drawing tools do, so dragging a corner onto another object's anchor scales exactly onto it:
|
||||
|
||||
```json
|
||||
{"name":"pointer_gesture","arguments":{"tool":"scale","events":[{"kind":"down","x":100,"y":100},{"kind":"up","x":100,"y":100},{"kind":"down","x":199,"y":198},{"kind":"drag","x":302,"y":262},{"kind":"up","x":302,"y":262}]}}
|
||||
```
|
||||
|
||||
Not read yet: Construction Guides and their Angles, and Spacing Guides (the tools draw neither).
|
||||
|
||||
## Type preferences
|
||||
|
||||
Reference in new issue
Block a user