From 2e9870b1d049952e58195090b07f992c8915b25d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 19:23:19 -0700 Subject: [PATCH] Rotate View: a Space+Shift drag rotates the view from any tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drag with Space and Shift held panned the view, as a Space drag does. With Space and Shift held and Cmd up, a drag on the canvas now turns the view about the canvas center, as a Rotate View tool drag does, whatever the tool. Shift does not round the angle to 15° as it does with the Rotate View tool, and the drag keeps turning the view when Shift is let go. Space alone still pans, and Cmd+Space still zooms with or without Shift. The pointer shows the rotate cursor while Space and Shift are held without Cmd, and during the drag. The Rotate View tool also shows it now, where it showed the arrow. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/canvas.rs | 108 ++++++++++++++++++++++++++++++++--- docs/target-app-parity.md | 1 + 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d098dc98c..381148f59 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -82,9 +82,12 @@ enum Drag { held: f32, moved: bool, }, + /// A Rotate View tool drag, or a Space+Shift drag with any tool. With `snaps` (the Rotate + /// View tool), Shift rounds the angle to 15°. RotateView { start_angle: f64, start_rot: f64, + snaps: bool, }, /// A Selection tool move dragged off the canvas: the panels get the art /// ([`widgets::PanelDrag::Art`]). @@ -526,12 +529,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if resp.hovered() { let m = ui.input(|i| i.modifiers); let space = ui.input(|i| i.key_down(egui::Key::Space)); - let panning = matches!(ui.data(|d| d.get_temp::(drag_id())), Some(Drag::Pan { .. })); + 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 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" }; // 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 { Some(Cursor::HandGrab) + } else if rotating { + Some(Cursor::Rotate) } else if zooming { Some(if m.alt { Cursor::ZoomOut } else { Cursor::ZoomIn }) } else if space || app.session.tool_id() == "hand" { @@ -735,10 +744,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } let tool = app.session.tool_id(); - // Held, Space is the Hand tool for the moment, and Cmd+Space the Zoom tool (with Alt, zooming - // out), whatever the tool. + // Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and + // Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool. let zoom_mode = if space { m.command } else { tool == "zoom" }; - let pan_mode = if space { !m.command } else { tool == "hand" }; + let rotate_mode = if space { m.shift && !m.command } else { tool == "rotateView" }; + let pan_mode = if space { !m.command && !m.shift } else { tool == "hand" }; let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. })); // egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a // press on the canvas itself reaches the tools, else it would land at the canvas's centre. @@ -761,9 +771,9 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } } } else if pan_mode { Drag::Pan { start: p, center: v.center, middle: false } - } else if tool == "rotateView" { + } else if rotate_mode { let c = rect.center(); - Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation } + Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space } } else { // Cmd with another tool drags with the selection tool used last (the session lends it). let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) }; @@ -795,11 +805,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: vm.center = center - d; } } - Drag::RotateView { start_angle, start_rot } => { + Drag::RotateView { start_angle, start_rot, snaps } => { let c = rect.center(); let a = (p.y - c.y).atan2(p.x - c.x) as f64; let mut deg = start_rot + (a - start_angle).to_degrees(); - if m.shift { + if snaps && m.shift { deg = (deg / 15.0).round() * 15.0; } if let Some(vm) = app.view_mut() { @@ -5117,6 +5127,88 @@ mod tests { assert!((after.center.x - (before.center.x - 30.0 / before.zoom)).abs() < 1e-6, "{before:?} → {after:?}"); } + #[test] + fn space_shift_drag_rotates_the_view_whatever_the_tool() { + 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.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let c = app.canvas_rect.unwrap().center(); + 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 }; + // `drag` drags from `from` to `to` with `held` down, and with Space as well when `with_space`. + let drag = |app: &mut VectorcraftApp, held: Modifiers, with_space: bool, from: Pos2, to: Pos2| { + let keys = |pressed| if with_space { vec![space(pressed, held)] } else { vec![] }; + frame(app, &ctx, [vec![Event::ModifiersChanged(held)], keys(true), vec![Event::PointerMoved(from)]].concat()); + frame(app, &ctx, vec![button(from, true, held)]); + frame(app, &ctx, vec![Event::PointerMoved(to)]); + frame(app, &ctx, vec![button(to, false, held)]); + frame(app, &ctx, [keys(false), vec![Event::ModifiersChanged(Modifiers::NONE)]].concat()); + }; + // `at(deg)` is the point 100 points from the canvas center, `deg` degrees clockwise from the right. + let at = |deg: f32| c + 100.0 * vec2(deg.to_radians().cos(), deg.to_radians().sin()); + let before = *app.view().unwrap(); + drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(90.0)); + let after = *app.view().unwrap(); + assert!((after.rotation - 90.0).abs() < 1e-3, "{after:?}"); + assert_eq!((after.center, after.zoom), (before.center, before.zoom), "the view turns about its center"); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing"); + // Shift does not round the angle: 90° and 20° more make 110°, not 105°. + drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(20.0)); + assert!((app.view().unwrap().rotation - 110.0).abs() < 1e-3, "{:?}", app.view()); + // Space alone still pans, and Cmd+Shift+Space still zooms. + let v = *app.view().unwrap(); + drag(&mut app, Modifiers::NONE, true, c, c + vec2(30.0, 0.0)); + let panned = *app.view().unwrap(); + assert_eq!((panned.rotation, panned.zoom), (v.rotation, v.zoom)); + assert_ne!(panned.center, v.center, "Space pans"); + drag(&mut app, Modifiers::COMMAND | Modifiers::SHIFT, true, c, c); + assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(v.zoom, true), "Cmd+Shift+Space click zooms in"); + assert_eq!(app.view().unwrap().rotation, v.rotation); + // The Rotate View tool's Shift still rounds to 15°. + app.select_tool("rotateView"); + app.view_mut().unwrap().rotation = 0.0; + drag(&mut app, Modifiers::SHIFT, false, at(0.0), at(20.0)); + assert_eq!(app.view().unwrap().rotation, 15.0); + drag(&mut app, Modifiers::NONE, false, at(0.0), at(20.0)); + assert!((app.view().unwrap().rotation - 35.0).abs() < 1e-3, "{:?}", app.view()); + } + + #[test] + fn the_rotate_cursor_shows_for_space_shift_and_the_rotate_view_tool() { + 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.select_tool("rectangle"); + 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. On macOS + // the window paints the glyph only with the focus, and on the other desktop builds (not + // Wayland, which the tests do not set) the window sets the system cursor next to the bitmap. + 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, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT }; + assert_ne!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias); + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT)]), CursorIcon::Alias); + // Shift let go during the drag keeps the rotate cursor; after it, Space shows the hand. + shown(&mut app, vec![button(c, true)]); + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(c + vec2(20.0, 30.0))]), CursorIcon::Alias); + assert_eq!(shown(&mut app, vec![button(c + vec2(20.0, 30.0), false)]), CursorIcon::Grab); + // Cmd+Shift+Space zooms. + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT)]), CursorIcon::ZoomIn); + assert_ne!(shown(&mut app, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]), CursorIcon::Alias); + app.select_tool("rotateView"); + assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias, "the Rotate View tool"); + } + #[test] fn space_pressed_mid_drag_moves_what_the_tool_draws() { use egui::{Event, Modifiers}; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cb59e74b0..9901ddb37 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -502,6 +502,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. - **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked. - **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. + 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, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it. In the Swatches panel, Alt-dragging a swatch onto another swatch of its kind (not a tint swatch) replaces that swatch's color, gradient or pattern, and the swatch keeps its name and place.