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.