Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 517b166722 Fix #485: the mouse wheel steps the focused numeric field
The wheel did nothing over numeric fields. As in Illustrator, once a numeric
field has the focus, turning the wheel over it steps its value like Up/Down:
a notch a step, Shift ten, Ctrl/Cmd a tenth, applied at once with the new value
selected.

- widgets::step_value (was step_with_arrows) reads the arrows and the wheel
  through one path for num_field, plain/mixed fields and the spin fields built
  on them (panels, the Control bar, dialogs, the Font Size and Leading combo
  boxes), and now opt_field (Stroke dash/gap) too. Trackpad and fine-wheel
  points add up to notches; the rest carries to the next turn.
- While the pointer is over the focused field the panel under it doesn't
  scroll; an unfocused field, or the pointer anywhere else, leaves the wheel to
  the panel and the canvas as before.
- The dialogs' and preferences' egui drag values become the same recessed
  number fields (widgets::range_field: clamped to their range, rounded to their
  precision), so they type, step and scroll like every other numeric field.
- Headless tests for the wheel (units, modifiers, points, focus and pointer),
  and for the panel scrolling or not; docs/control-protocol.md and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 20:21:39 +07:00
bflatasticandClaude Opus 5.5 377f236687 Fix #483: Shift-drag marquees toggle the selection; the Lasso's Alt keeps the rest
A Shift-drag marquee only ever added to the selection, so there was no way to
take some of the selected objects out with it. As in Illustrator, Shift-drag
now toggles what the marquee reaches: the selected ones are deselected, the
others selected, the rest of the selection stays.

- Selection tool: the Shift marquee runs select.toggle, which now also takes
  {ids} (each one toggles).
- Direct Selection and Group Selection: the Shift marquee toggles the anchors
  inside it (select.anchorsMany mode "toggle"); a path left with none leaves
  the selection, one with all of them is selected whole again.
- select.anchorsMany takes mode "set"|"add"|"toggle"|"subtract" (add: true is
  still mode add). Adding anchors of a path selected whole keeps it whole, and
  anchor references outside the path are ignored.
- Lasso: Shift adds and Alt subtracts through the same command, so an Alt lasso
  no longer drops selected type, groups and other non-path objects.
- Tests at the tool, engine (Session::pointer) and MCP (pointer_gesture) levels,
  command sweep cases for the new modes; docs/mcp.md and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 20:21:36 +07:00
20 changed files with 426 additions and 111 deletions

No files matched your search

+3 -3
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), 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); 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).
+61 -9
View File
@@ -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);
}
+66
View File
@@ -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();
+4
View File
@@ -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]})),
+24
View File
@@ -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
View File
@@ -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();
+28 -12
View File
@@ -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();
+9 -19
View File
@@ -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)));
}
}
+1 -2
View File
@@ -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));
}
});
+5 -8
View File
@@ -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);
+4 -3
View File
@@ -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();
+9 -10
View File
@@ -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();
+2 -2
View File
@@ -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));
}
});
}
+5 -5
View File
@@ -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();
+8 -9
View File
@@ -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));
}
});
+87
View File
@@ -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());
}
}
}
+64 -14
View File
@@ -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)))
}
}
+1 -1
View File
@@ -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 |
+10
View File
@@ -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