From 3aebccaf06d44391b644c890da3d06ce81275699 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 18:50:45 +0700 Subject: [PATCH] Zoom tool: Space held while dragging moves the zoom marquee Squashed from #4372 onto main. --- crates/ui-egui/src/canvas.rs | 7 +- crates/ui-egui/src/canvas/input.rs | 71 ++++++++++++- crates/ui-egui/src/canvas/tests.rs | 162 +++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 4 files changed, 238 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0de1171bd..6d3b98f64 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -370,9 +370,12 @@ fn canvas_cursor(app: &mut VectorcraftApp, ui: &Ui, view_info: vectorcraft_engin let space = ui.input(|i| i.key_down(egui::Key::Space)); let drag = ui.data(|d| d.get_temp::(drag_id())); let panning = matches!(drag, Some(Drag::Pan { .. })); - let zooming = if space { m.command } else { app.session.tool_id() == "zoom" }; + // A zoom drag keeps its cursor when Space is pressed (with or without Shift) or Cmd is let go. + let zoom_drag = matches!(drag, Some(Drag::ZoomBox { .. } | Drag::ZoomScrub { .. })); + let zooming = zoom_drag || if space { m.command } else { app.session.tool_id() == "zoom" }; // A rotating drag keeps its cursor when Shift is let go. - let rotating = matches!(drag, Some(Drag::RotateView { .. })) || if space { m.shift && !m.command } else { app.session.tool_id() == "rotateView" }; + let rotating = matches!(drag, Some(Drag::RotateView { .. })) + || (!zoom_drag && if space { m.shift && !m.command } else { app.session.tool_id() == "rotateView" }); // The hand and the magnifier are drawn like the tool cursors below: Windows has no // system cursor for them (winit shows the four-headed move cursor for Grab). let view_cursor = if panning { diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 3b54c2c88..6e362f9b4 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -262,12 +262,19 @@ fn begin_drag(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { // A new press has not dragged yet, whatever an interrupted one left behind. dd.remove::(tool_drag_doc_id()); dd.insert_temp(tool_press_doc_id(), xf.to_doc(p)); + // Space held at the press (Cmd+Space lends the Zoom tool) sizes the marquee until + // it is let go ([`zoom_marquee_start`]). + if matches!(d, Drag::ZoomBox { .. }) { + dd.insert_temp(zoom_space_free_id(), !space); + } }); } /// A gesture held at `p`: the view follows, or the tool gets drag events. fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) { let Pointing { resp, rect, pointer, m, space, xf, view, .. } = *f; + // Where the pointer was in the frame before: a zoom marquee moved with Space follows it. + let before: Option = ui.data(|dd| dd.get_temp(drag_pos_id())); ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p)); match d { Drag::Pan { start, center, .. } => { @@ -288,7 +295,11 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) vm.rotation = vectorcraft_geom::normalize_deg(deg); } } - Drag::ZoomBox { start } => marquee(ui.painter(), egui::Rect::from_two_pos(start, p)), + Drag::ZoomBox { start } => { + let start = zoom_marquee_start(ui, start, before, p, space); + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomBox { start })); + marquee(ui.painter(), egui::Rect::from_two_pos(start, p)); + } Drag::ZoomScrub { start, held, moved } => { let dt = ui.input(|i| i.unstable_dt).min(0.25); let (held, moved) = (held + dt, moved || (p - start).length() > SCRUB_SLOP); @@ -355,6 +366,8 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po let Pointing { resp, rect, m, space, xf, view, .. } = *f; // The last position the tool's drag got: none if the press never dragged. let last_drag: Option = ui.data(|dd| dd.get_temp(tool_drag_doc_id())); + // Where the pointer was in the frame before: a zoom marquee moved with Space follows it. + let before: Option = ui.data(|dd| dd.get_temp(drag_pos_id())); ui.data_mut(|dd| { dd.remove::(drag_id()); dd.remove::(drag_pos_id()); @@ -363,6 +376,8 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po }); match d { Drag::ZoomBox { start } => { + let start = zoom_marquee_start(ui, start, before, p, space); + ui.data_mut(|dd| dd.remove::(zoom_space_free_id())); let r = egui::Rect::from_two_pos(start, p); if let Some(vm) = app.view_mut() { if r.width() > 8.0 && r.height() > 8.0 { @@ -442,6 +457,60 @@ fn double_click(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { } } +/// Whether Space moves the Zoom tool's marquee in the drag under way ([`zoom_marquee_start`]). +fn zoom_space_free_id() -> egui::Id { + egui::Id::unique("canvas-zoom-space-free") +} + +/// The start corner of the Zoom tool's marquee in this frame. With Space held, the marquee moves +/// as far as the pointer did since `before` and keeps its size; with Space let go, the drag sizes +/// it again from where it moved to. Space held since the press (Cmd+Space lends the Zoom tool) +/// sizes the marquee until it is let go. A marquee with no size yet is sized by the movement, so +/// Space pressed before the pointer moves does not keep it at the press point. The frame's pointer +/// moves and Space presses and releases are taken in the order they came, so each move follows the +/// Space state it came with. +fn zoom_marquee_start(ui: &Ui, start: Pos2, before: Option, p: Pos2, space: bool) -> Pos2 { + enum Step { + Space(bool), + Move(Pos2), + } + let id = zoom_space_free_id(); + let steps: Vec = ui.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key: egui::Key::Space, pressed, repeat: false, .. } => Some(Step::Space(*pressed)), + egui::Event::PointerMoved(q) => Some(Step::Move(*q)), + _ => None, + }) + .collect() + }); + // Space as it was when the frame began: the opposite of its first press or release, else as it is now. + let mut held = steps.iter().find_map(|s| if let Step::Space(down) = s { Some(!down) } else { None }).unwrap_or(space); + let mut free = !held || ui.data(|d| d.get_temp::(id)).unwrap_or(true); + let (mut start, mut at) = (start, before); + for s in steps.into_iter().chain(std::iter::once(Step::Move(p))) { + match s { + Step::Space(down) => { + held = down; + free |= !down; + } + Step::Move(q) => { + if let Some(b) = at + && held + && free + && b != start + { + start += q - b; + } + at = Some(q); + } + } + } + ui.data_mut(|d| d.insert_temp(id, free)); + start +} + /// A marquee around screen rectangle `r`: dark dashes over a light line, so it shows on a white /// artboard and on the grey pasteboard alike (#725). fn marquee(p: &egui::Painter, r: egui::Rect) { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index bbee169bc..7b2ddba66 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4313,6 +4313,168 @@ fn the_zoom_tool_zooms_a_rotated_view_to_the_area_dragged_across() { } } +/// Space held while the Zoom tool drags a marquee moves the marquee and keeps its size. When Space +/// is let go, the drag sizes the marquee again from where it moved to, and the release zooms to the +/// moved marquee. +/// Space held since a Cmd+Space press sizes the marquee. +#[test] +fn space_moves_the_zoom_marquee_while_dragging() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + app.select_tool("zoom"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() - vec2(100.0, 50.0); + let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + let drawn = |app: &mut VectorcraftApp| -> Vec { + let lines: Vec> = shapes(app, &ctx) + .into_iter() + .filter_map(|s| match s { + Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::from_white_alpha(230)) => Some(p.points), + _ => None, + }) + .collect(); + let [line] = &lines[..] else { panic!("one marquee: {lines:?}") }; + line.clone() + }; + let z0 = app.view().unwrap().zoom; + frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(at, true, Modifiers::NONE)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(100.0, 50.0))]); + frame(&mut app, &ctx, vec![space(true, Modifiers::NONE), Event::PointerMoved(at + vec2(140.0, 70.0))]); + let moved = drawn(&mut app); + assert_eq!(moved[0], at + vec2(40.0, 20.0), "Space moves the marquee: {moved:?}"); + assert_eq!(moved[2], at + vec2(140.0, 70.0), "and keeps its 100 × 50 size: {moved:?}"); + frame(&mut app, &ctx, vec![space(false, Modifiers::NONE), Event::PointerMoved(at + vec2(180.0, 95.0))]); + let sized = drawn(&mut app); + assert_eq!((sized[0], sized[2]), (at + vec2(40.0, 20.0), at + vec2(180.0, 95.0)), "let go, the drag sizes it again: {sized:?}"); + let middle = Xf::new(rect, app.view().unwrap()).to_doc(at + vec2(110.0, 57.5)); + frame(&mut app, &ctx, vec![button(at + vec2(180.0, 95.0), false, Modifiers::NONE)]); + let v = *app.view().unwrap(); + let want = z0 * f64::from((rect.width() / 140.0).min(rect.height() / 75.0)); + assert!((v.zoom - want).abs() < 1e-6, "the release zooms to the moved marquee: {z0} → {}, not {want}", v.zoom); + assert!(v.center.distance(middle) < 1e-6, "centered on it: {:?}, not {middle:?}", v.center); + // A Cmd+Space press: Space held from the press on sizes the marquee, also once Cmd is let go. + app.select_tool("selection"); + app.view_mut().unwrap().zoom = z0; + frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::COMMAND), space(true, Modifiers::COMMAND), Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(at, true, Modifiers::COMMAND)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(50.0, 25.0))]); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(at + vec2(100.0, 50.0))]); + let held = drawn(&mut app); + assert_eq!((held[0], held[2]), (at, at + vec2(100.0, 50.0)), "Space held since the press sizes it: {held:?}"); + frame(&mut app, &ctx, vec![button(at + vec2(100.0, 50.0), false, Modifiers::NONE)]); + frame(&mut app, &ctx, vec![space(false, Modifiers::NONE)]); + let want = z0 * f64::from((rect.width() / 100.0).min(rect.height() / 50.0)); + assert!((app.view().unwrap().zoom - want).abs() < 1e-6, "{z0} → {}, not {want}", app.view().unwrap().zoom); +} + +/// Space pressed after the Zoom tool's press and before the drag moves: the first movement sizes +/// the marquee, Space then moves it, and the release zooms to it instead of stepping the zoom. +#[test] +fn space_before_the_zoom_drag_moves_sizes_the_marquee_first() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + app.select_tool("zoom"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() - vec2(100.0, 50.0); + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let z0 = app.view().unwrap().zoom; + frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(at, true)]); + frame(&mut app, &ctx, vec![space(true), Event::PointerMoved(at + vec2(120.0, 60.0))]); + frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(140.0, 70.0))]); + let middle = Xf::new(rect, app.view().unwrap()).to_doc(at + vec2(80.0, 40.0)); + frame(&mut app, &ctx, vec![button(at + vec2(140.0, 70.0), false)]); + frame(&mut app, &ctx, vec![space(false)]); + let v = *app.view().unwrap(); + let want = z0 * f64::from((rect.width() / 120.0).min(rect.height() / 60.0)); + assert!((v.zoom - want).abs() < 1e-6, "the release zooms to the 120 × 60 marquee: {z0} → {}, not {want}", v.zoom); + assert!(v.center.distance(middle) < 1e-6, "moved by Space and centered on it: {:?}, not {middle:?}", v.center); +} + +/// Space pressed or let go between two pointer moves in one frame: the move before the change and +/// the move after it each follow the Space state they came with. +#[test] +fn the_zoom_marquee_follows_space_and_the_pointer_in_the_order_they_came() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + app.select_tool("zoom"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() - vec2(100.0, 50.0); + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let corners = |app: &mut VectorcraftApp| -> (Pos2, Pos2) { + let lines: Vec> = shapes(app, &ctx) + .into_iter() + .filter_map(|s| match s { + Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::from_white_alpha(230)) => Some(p.points), + _ => None, + }) + .collect(); + let [line] = &lines[..] else { panic!("one marquee: {lines:?}") }; + (line[0], line[2]) + }; + frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(at, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(100.0, 50.0))]); + frame(&mut app, &ctx, vec![space(true), Event::PointerMoved(at + vec2(140.0, 70.0))]); + // Moved with Space held, then sized after it was let go. + frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(160.0, 80.0)), space(false), Event::PointerMoved(at + vec2(200.0, 100.0))]); + assert_eq!(corners(&mut app), (at + vec2(60.0, 30.0), at + vec2(200.0, 100.0)), "moved, then sized"); + // Sized before Space was pressed, then moved with it held. + frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(210.0, 105.0)), space(true), Event::PointerMoved(at + vec2(230.0, 115.0))]); + assert_eq!(corners(&mut app), (at + vec2(80.0, 40.0), at + vec2(230.0, 115.0)), "sized, then moved"); + frame(&mut app, &ctx, vec![button(at + vec2(230.0, 115.0), false)]); + frame(&mut app, &ctx, vec![space(false)]); +} + +/// Space pressed while the Zoom tool drags keeps the zoom cursor: the drag does not turn into a +/// Hand tool pan, and with Shift also held it does not show the rotate cursor. +#[test] +fn space_during_a_zoom_drag_keeps_the_zoom_cursor() { + use egui::{CursorIcon, Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + app.select_tool("zoom"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let c = app.canvas_rect.unwrap().center(); + // `shown` returns the system cursor the canvas sets over a window without the focus, as in + // `the_rotate_cursor_shows_for_space_shift_and_the_rotate_view_tool`. + let shown = |app: &mut VectorcraftApp, events: Vec| { + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); + let mut out = ctx.run_ui(egui::RawInput { screen_rect, focused: false, events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.platform_output.cursor_icon + }; + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::ZoomIn); + shown(&mut app, vec![button(c, true)]); + shown(&mut app, vec![Event::PointerMoved(c + vec2(40.0, 30.0))]); + assert_eq!(shown(&mut app, vec![space(true), Event::PointerMoved(c + vec2(60.0, 40.0))]), CursorIcon::ZoomIn, "Space during the drag"); + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::SHIFT)]), CursorIcon::ZoomIn, "Space+Shift during the drag"); + shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE)]); + shown(&mut app, vec![button(c + vec2(60.0, 40.0), false)]); + assert_eq!(shown(&mut app, vec![]), CursorIcon::Grab, "after the drag, Space shows the hand"); + shown(&mut app, vec![space(false)]); +} + /// In a rotated view the Selection tool's marquee is drawn where the tool selects: through the /// screen corners of the document rectangle between the press and the pointer. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5514614fc..9f9a97dfb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -694,6 +694,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Duplicate Artboards, the Artboard tool's Alt-drag and Paste give the new artboard an id that no other artboard has, also when an artboard's id is the largest possible value (#1718). Deleting the copy or the original then leaves the other's guides. - **Touch Type:** a click with the Touch Type tool (Shift+T) selects one character and shows a box around it. Dragging the box or its lower-left handle moves the character. The upper-left handle scales it vertically, the lower-right handle scales it horizontally, the upper-right handle scales it proportionally, and the upper-middle handle rotates it. Each drag is one undo step that sets the character's kerning, baseline shift, scales or rotation, and the type stays editable (`text.touchType`; behavior reference: [Touch Type tool](https://web.archive.org/web/20261007185617/https://helpx.adobe.com/illustrator/using/tool-techniques/touch-type-tool.html), [Add text with the Type, Area Type, and Touch Type tools](https://web.archive.org/web/20260926095922/https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/add-text-illustrator-text-tools.html)). - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. + Space held while the Zoom tool drags a marquee moves the marquee and keeps its size ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). When Space is let go, the drag sizes the marquee again from where it moved to. A Space+Shift drag rotates the view whatever the tool, as a Rotate View tool drag does, and Shift does not round the angle to 15°. - **Swatches panel clicks:** Ctrl/Cmd-clicking New Swatch makes a spot color, and Ctrl/Cmd+Shift-clicking it makes a global process color. - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, art dragged onto the Swatches panel becomes a pattern swatch, and art dropped on the Brushes panel opens New Brush for that art; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.