diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index c35effb6f..75b155c51 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -78,6 +78,16 @@ pub(super) fn tool_drag_moved(ui: &Ui, xf: &Xf, p: Pos2, pointer: &egui::Pointer true } +/// Whether the tool should get another drag event at a pointer held still because Shift, Alt, +/// Cmd/Ctrl or Space went down or up (#4711): a scale switches to proportional or from the centre +/// at once. Records `m` as the modifiers last seen; only once the press has dragged. +pub(super) fn tool_drag_mods_changed(ui: &Ui, m: Mods) -> bool { + let id = egui::Id::unique("canvas-tool-drag-mods"); + let (dragged, last) = ui.data(|d| (d.get_temp::(tool_drag_doc_id()).is_some(), d.get_temp::(id))); + ui.data_mut(|d| d.insert_temp(id, m)); + dragged && last.is_some_and(|l| l != m) +} + /// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch /// input, else the pressure in the host's feed while a pen is the pointer that moved last /// ([`crate::Services::stylus`]: [`StylusFeed::press`] for a new press, [`StylusFeed::held`] diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index e254e8313..223c24d1f 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -1,7 +1,7 @@ use super::artboards::{artboard_name_at, begin_artboard_name_edit, name_press_id, select_named_artboard}; use super::drag::{ HoverContext, apply_requests, dispatch, drag_art_out, drag_id, drag_layer_id, drag_pos_id, drop_press, mods, on_canvas, pen_pressure, - tool_drag_doc_id, tool_drag_moved, tool_frame_moves, tool_holds, tool_press_doc_id, + tool_drag_doc_id, tool_drag_mods_changed, tool_drag_moved, tool_frame_moves, tool_holds, tool_press_doc_id, }; use super::view::{ HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, rotate_about, scroll_view, touch_gestures, trackpad_turn, @@ -361,7 +361,8 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) // the last position only, so a costly drag preview runs once a frame. let moves = tool_frame_moves(ui, app); // A frame whose pointer ends where the last one did still moved in between. - if tool_drag_moved(ui, &xf, p, pointer) || !moves.is_empty() { + let mods_changed = tool_drag_mods_changed(ui, mods(m, space)); + if tool_drag_moved(ui, &xf, p, pointer) || mods_changed || !moves.is_empty() { for (at, force) in moves { let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false)); dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 721094bd3..0c238693b 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -6054,6 +6054,43 @@ fn tool_drag_moved_without_pointer_delta() { assert!(moved, "a new document position runs even when egui reports no delta"); } +/// #4711: Shift pressed and released while a corner handle is held still scales proportionally and +/// then freely again at once, without waiting for the pointer to move. +#[test] +fn a_modifier_changed_mid_scale_applies_with_the_pointer_held_still() { + 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.ui.view.smart_guides = false; + app.ui.view.snap_to_point = false; + let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let with = |app: &mut VectorcraftApp, mut events: Vec, modifiers: Modifiers| { + events.insert(0, Event::ModifiersChanged(modifiers)); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let height = + |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().height(); + let corner = xf.to_screen(Point::new(200.0, 150.0)); + let to = xf.to_screen(Point::new(300.0, 150.0)); + with(&mut app, vec![Event::PointerMoved(corner)], Modifiers::NONE); + with(&mut app, vec![button(corner, true)], Modifiers::NONE); + with(&mut app, vec![Event::PointerMoved(to)], Modifiers::NONE); + assert!((height(&app) - 50.0).abs() < 1e-6, "a free scale keeps the height: {}", height(&app)); + with(&mut app, vec![], Modifiers::SHIFT); + assert!((height(&app) - 100.0).abs() < 1e-6, "Shift scales proportionally at once: {}", height(&app)); + with(&mut app, vec![], Modifiers::NONE); + assert!((height(&app) - 50.0).abs() < 1e-6, "releasing Shift frees it at once: {}", height(&app)); + with(&mut app, vec![button(to, false)], Modifiers::NONE); + assert!(app.session.active().unwrap().interaction.is_none(), "committed"); +} + /// #834 review: a press held still is not a drag. Before the press has dragged, the first held /// frame without a delta at the press's own position sends nothing, so a click stays a click; /// the first move after it does.