From 3ea2bba6e3c873b63475e1f2d7a50c2fa3e679ef Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 03:13:50 -0700 Subject: [PATCH] Blend: Escape drops the Blend tool's first pick After a click picked the first object with the Blend tool, Escape did nothing: the pick stayed, and the next click on another object blended the two. After a blend was made, Escape did not stop the next click adding to it either. Escape now drops the object picked first and stops the next click adding to the blend just made, as a click on empty canvas does, so the next click picks a new first object. The document and the selection stay as they are, and no undo step is added. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/tests/blendopts.rs | 31 ++++++++++++++-- crates/tools/src/meshblend.rs | 53 +++++++++++++++++++++++++++- docs/mcp.md | 4 ++- docs/target-app-parity.md | 1 + 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index f2bc3f279..97f356078 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -1,10 +1,11 @@ //! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool: -//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18). +//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18), and +//! Escape dropping the first pick. use serde_json::{Value, json}; use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec}; use vectorcraft_doc::{Node, NodeKind}; -use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; use crate::*; @@ -140,6 +141,32 @@ fn the_blend_tool_blends_from_clicked_anchors_and_keeps_adding() { assert!(matches!(r.unwrap().as_slice(), [UiRequest::Dialog(k, _)] if k == "blendOptions")); } +#[test] +fn escape_drops_the_blend_tools_first_pick() { + let mut s = session(); + let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); + s.select_tool("blend", ViewInfo::default()).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); + let esc = |s: &mut Session| s.tool_key(ToolKey::Escape, Mods::default(), ViewInfo::default()).unwrap(); + click(&mut s, 20.0, 20.0); + assert!(s.tool_busy()); + let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len()); + assert!(esc(&mut s).is_empty()); + assert!(!s.tool_busy(), "Escape drops the pick"); + assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "and changes nothing"); + // The next two clicks blend b and c; a is not part of it. + click(&mut s, 220.0, 20.0); + assert_eq!(s.doc().unwrap().doc.layers, layers, "b is picked first"); + click(&mut s, 420.0, 20.0); + let g = s.doc().unwrap().selection.objects[0]; + assert_eq!(spec(&s, g).0, vec![b, c]); + // Escape after the blend is made: the next click starts a new blend instead of adding to it. + esc(&mut s); + click(&mut s, 20.0, 20.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(spec(&s, g).0, vec![b, c], "a was not added"); +} + #[test] fn start_points_round_trip_and_old_blends_load() { let mut s = session(); diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index bea8546e6..d6a46faf0 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -3,7 +3,7 @@ //! - Blend: click object A, then object B → `object.blend.make {ids: [A, B]}`; clicking an anchor //! point of a path blends from that point (`starts`). Each further click adds the object as one //! more key of the new blend (as does clicking a blend first). Double-click or Alt-click opens -//! Blend Options (dialog `blendOptions`). Clicking empty canvas starts over. +//! Blend Options (dialog `blendOptions`). Clicking empty canvas or pressing Escape starts over. //! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through //! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's //! new point takes the current fill color in its own model, unless Shift); drag a mesh point, @@ -136,6 +136,14 @@ impl Tool for BlendTool { fn busy(&self) -> bool { self.first.is_some() } + /// Escape drops the object picked first and stops adding to the blend just made, so the next + /// click picks a new first object. The document and the selection stay as they are. + fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { + if key == crate::ToolKey::Escape { + (self.first, self.chained) = (None, false); + } + vec![] + } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { (self.first, self.chained) = (None, false); vec![] @@ -340,6 +348,49 @@ mod tests { assert!(t.first(&c).is_none()); } + /// Escape drops the object picked first, and stops adding to the blend just made: the next + /// click is a new first pick, and the click after it blends with that one. + #[test] + fn escape_drops_the_blend_tools_first_pick() { + let (mut d, a) = doc_with_rect(); + let l = d.layers[0].id; + let (b, g) = (d.alloc_id(), d.alloc_id()); + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (k1, k2) = (d.alloc_id(), d.alloc_id()); + let key = |id: NodeId, x: f64| { + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); + std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) + }; + let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() }); + d.insert(Some(l), 2, blend).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let picks = |id: NodeId| vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + let down = |x: f64, y: f64| PointerEvent::new(PointerKind::Down, x, y); + let esc = |t: &mut BlendTool| t.key(&c, crate::ToolKey::Escape, Mods::default()); + let mut t = BlendTool::default(); + assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a)); + assert!(t.busy() && !t.overlays(&c).is_empty()); + assert!(t.key(&c, crate::ToolKey::Enter, Mods::default()).is_empty()); + assert!(t.busy(), "Enter keeps the pick"); + assert!(esc(&mut t).is_empty(), "Escape changes nothing in the document"); + assert!(!t.busy() && t.first(&c).is_none() && t.overlays(&c).is_empty(), "Escape drops the pick"); + // The next click picks b first instead of blending a with it. + assert_eq!(t.pointer(&c, &down(320.0, 120.0)), picks(b)); + assert_eq!(t.pointer(&c, &down(150.0, 150.0)), vec![Action::Exec("object.blend.make".into(), json!({"ids": [b.0, a.0]}))]); + // After a blend is made, Escape stops the next click adding to it. + let mut s = Selection::default(); + s.set([g]); + let c = cx(&d, &s, &p); + let mut t = BlendTool { first: None, chained: true }; + assert!(t.first(&c).is_some()); + assert!(t.key(&c, crate::ToolKey::Escape, Mods::default()).is_empty()); + assert!(t.first(&c).is_none(), "Escape ends adding to the blend just made"); + assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a)); + assert!(esc(&mut BlendTool::default()).is_empty(), "nothing to drop"); + } + #[test] fn mesh_tool_click_path_creates_mesh() { let (d, a) = doc_with_rect(); diff --git a/docs/mcp.md b/docs/mcp.md index 2a2ab7516..d3af1e0c6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5284,7 +5284,9 @@ shows, and so do the click-to-size dialogs (`perspective.draw` with `at`: the cl `object.blend.make` blends the selected objects (or `ids`) into a live blend. Spacing and orientation default to what `object.blend.options` set with no blend selected (`object.blend.info` reads them: `target: "defaults"`), else Smooth Color and Align to Page. `starts` gives, per object, the anchor of its first subpath the blend starts -from (as the Blend tool's clicks on anchor points do; an open path's last anchor runs it the other way). A blend +from (as the Blend tool's clicks on anchor points do; an open path's last anchor runs it the other way). With the +Blend tool, `press_key` Escape drops the object clicked first and stops the next click adding to the blend +just made; the document and the selection stay as they are. A blend among the objects takes the others in as more key objects, keeping its options, name and transparency: ```json diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c90db991a..b7f1c55af 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -859,6 +859,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`). Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. + With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`. Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`). Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. The Space, Move All, Rotate All and Select/Move widgets, Detach, the tool's options dialog and the Properties panel section are missing. - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged.