From 08868afc55b8f46cfdb4d4d100928cc34ba8f594 Mon Sep 17 00:00:00 2001 From: highperfocused Date: Thu, 15 Oct 2026 20:06:10 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Selection:=20=E2=87=A7=20mid-drag=20locks?= =?UTF-8?q?=20a=20selection-tool=20move=20to=20the=20axis=20(#6231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/canvas.rs | 8 +++++++ crates/ui-egui/src/marquee_tests.rs | 35 +++++++++++++++++++++++++++++ crates/ui-egui/src/move_mods.rs | 16 +++++++++++++ 3 files changed, 59 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 391ad464fd..0c67689c33 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -165,6 +165,9 @@ pub struct Drag { /// `Some(false)` moves the outline, `Some(true)` moves the floating piece (`select.float`), as /// every Move-tool drag with a selection does (`move_ui::moves_selected_pixels_with`). pub sel_move: Option, + /// The last ⇧-locked offset of a selection-tool drag of the selection (`move_mods`, #6231); + /// keeps the locked axis stable between events. The Move tool's own drags use `move_mods`. + pub sel_axis: Option<[f64; 2]>, pub lasso: Option, } @@ -182,6 +185,7 @@ impl Drag { released: egui::Modifiers::NONE, reposition: false, sel_move: None, + sel_axis: None, lasso: None, } } @@ -5053,6 +5057,9 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: // ⇧: straight 0/45/90° strokes, 45° gradient angles (stroke_constraint.rs). let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom); + // ⇧: a selection-tool drag of the selection locks to the dominant axis (#6231). The Move + // tool's own drags were already locked by `move_mods::filter_event`. + let [x, y] = crate::move_mods::selection_drag_to(d, [x, y], mods.shift && tool != Tool::Move); let moved = d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25); // A held pen repeats its point now and then, so the stroke's time keeps advancing. let held = app.stylus.last_point_ms().is_some_and(|t| app.stylus.clock_ms - t >= HOLD_REPEAT_MS) @@ -5473,6 +5480,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom); + let [x, y] = crate::move_mods::selection_drag_to(&mut d, [x, y], mods.shift && app.active_tool() != Tool::Move); // A Move-tool click (released where it was pressed) selects, it never moves: snapping // the release point would otherwise nudge the layer onto a nearby edge. if d.tool == Tool::Move && d.points.len() < 2 && matches!(raw, ToolEvent::Up { x, y } if [x, y] == d.start) { diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 169d4588b9..67862d9dc2 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -500,6 +500,41 @@ fn drag_inside_the_selection_moves_the_outline() { assert_eq!(sel(&app), None); } +/// #6231: ⇧ held during a selection-tool drag of the selection locks the move to the dominant +/// axis (⇧ at the press still means add-to-selection, so the lock only applies mid-drag). +#[test] +fn shift_constrains_a_selection_drag_to_an_axis() { + use crate::canvas::{ToolEvent, tool_event}; + let (mut app, _layer) = painted(); + let sel = |app: &PhotocraftApp| app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()); + let drag_shift = |app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]| { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, Modifiers::SHIFT); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, Modifiers::SHIFT); + }; + drag_shift(&mut app, [20.0, 20.0], [40.0, 26.0]); + assert_eq!(sel(&app), Some(Rect::new(30, 10, 50, 30)), "near-horizontal drag moves x only"); + drag_shift(&mut app, [40.0, 20.0], [44.0, 36.0]); + assert_eq!(sel(&app), Some(Rect::new(30, 26, 50, 46)), "near-vertical drag moves y only"); + // Without ⇧ the drag is free (the outline already sits at (30,26,50,46)). + drag(&mut app, [40.0, 30.0], [50.0, 20.0], Modifiers::NONE); + assert_eq!(sel(&app), Some(Rect::new(40, 16, 60, 36)), "plain drag moves both axes"); +} + +/// #6231: ⇧ also locks a ⌘-drag of the selected pixels to the dominant axis. +#[test] +fn shift_constrains_a_floating_selection_drag() { + use crate::canvas::{ToolEvent, tool_event}; + let (mut app, _layer) = painted(); + let offset = |app: &PhotocraftApp| photocraft_engine::float_cmds::floating(app.session.active().unwrap()).map(|f| f.offset); + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, Modifiers::COMMAND); + tool_event(&mut app, ToolEvent::Move { x: 34.0, y: 28.0, pressure: 1.0 }, cmd_shift); + tool_event(&mut app, ToolEvent::Up { x: 34.0, y: 28.0 }, cmd_shift); + assert_eq!(offset(&app), Some((14, 0)), "locked to the horizontal axis"); + app.run("select.drop", json!({})).unwrap(); +} + /// ⌘-drag cuts the selected pixels into a floating piece (`select.float`): shown at the pointer /// while dragging, moved again by plain drags, put back by Undo, dropped by Deselect. #[test] diff --git a/crates/ui-egui/src/move_mods.rs b/crates/ui-egui/src/move_mods.rs index 64b3a65603..32bd9e2f0a 100644 --- a/crates/ui-egui/src/move_mods.rs +++ b/crates/ui-egui/src/move_mods.rs @@ -152,6 +152,22 @@ fn drag_to(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; [start[0] + d[0], start[1] + d[1]] } +/// ⇧ while a selection tool drags the selection (#6231): lock the move to the dominant axis, as +/// the Move tool's layer drags do. The caller skips this when [`filter_event`] has already +/// rewritten the event (the Move tool itself); it covers the `sel_move` drags the marquee and +/// lasso tools start, including ⌘-drags of the selected pixels. The locked offset is kept on the +/// drag between events so the axis doesn't flicker near 45°. +pub(crate) fn selection_drag_to(d: &mut crate::canvas::Drag, p: [f64; 2], shift: bool) -> [f64; 2] { + if d.sel_move.is_none() || !shift { + d.sel_axis = None; + return p; + } + let delta = [p[0] - d.start[0], p[1] - d.start[1]]; + let locked = constrain(delta, d.sel_axis, MOVE_DIRECTIONS); + d.sel_axis = Some(locked); + [d.start[0] + locked[0], d.start[1] + locked[1]] +} + /// Call after the Move tool finished its drag: an ⌥-drag's copy and move become one step. pub fn finish(app: &mut PhotocraftApp) { let st = std::mem::take(&mut app.move_mods); From 4efebbe2bf749aae71ea01353acab7da0ebbe63d Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 22 Oct 2026 21:56:36 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Integration:=20lock=20a=20Shift=20selection?= =?UTF-8?q?=20drag=20to=20multiples=20of=2045=C2=B0=20(#6254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photoshop constrains a Shift-mid-drag selection move to multiples of 45° ("Move a selection border"), and the Move tool's drag of selected pixels already uses TRANSFORM_DIRECTIONS (8). selection_drag_to used MOVE_DIRECTIONS (4), so a near-diagonal Shift drag (delta 13,11) snapped to the horizontal axis (13,0) instead of 45° (12,12). Test: shift_constrains_a_selection_drag_to_an_axis gains a diagonal case (fails with 4 directions); the floating-drag test moves to (34,22) so it stays an axis case with 8 directions. Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/canvas.rs | 4 ++-- crates/ui-egui/src/marquee_tests.rs | 13 ++++++++----- crates/ui-egui/src/move_mods.rs | 9 +++++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9ed656f17f..cb8ec49925 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -267,7 +267,7 @@ pub struct Drag { /// every Move-tool drag with a selection does (`move_ui::moves_selected_pixels_with`). pub sel_move: Option, /// The last ⇧-locked offset of a selection-tool drag of the selection (`move_mods`, #6231); - /// keeps the locked axis stable between events. The Move tool's own drags use `move_mods`. + /// keeps the locked direction stable between events. The Move tool's own drags use `move_mods`. pub sel_axis: Option<[f64; 2]>, pub lasso: Option, } @@ -5724,7 +5724,7 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: // ⇧: straight 0/45/90° strokes, 45° gradient angles (stroke_constraint.rs). let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom); - // ⇧: a selection-tool drag of the selection locks to the dominant axis (#6231). The Move + // ⇧: a selection-tool drag of the selection locks to multiples of 45° (#6231). The Move // tool's own drags were already locked by `move_mods::filter_event`. let [x, y] = crate::move_mods::selection_drag_to(d, [x, y], mods.shift && tool != Tool::Move); let moved = d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25); diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 283b4c71b0..87ff48ddb4 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -655,8 +655,8 @@ fn drag_inside_the_selection_moves_the_outline() { assert_eq!(sel(&app), None); } -/// #6231: ⇧ held during a selection-tool drag of the selection locks the move to the dominant -/// axis (⇧ at the press still means add-to-selection, so the lock only applies mid-drag). +/// #6231: ⇧ held during a selection-tool drag of the selection locks the move to multiples of +/// 45° (⇧ at the press still means add-to-selection, so the lock only applies mid-drag). #[test] fn shift_constrains_a_selection_drag_to_an_axis() { use crate::canvas::{ToolEvent, tool_event}; @@ -674,9 +674,12 @@ fn shift_constrains_a_selection_drag_to_an_axis() { // Without ⇧ the drag is free (the outline already sits at (30,26,50,46)). drag(&mut app, [40.0, 30.0], [50.0, 20.0], Modifiers::NONE); assert_eq!(sel(&app), Some(Rect::new(40, 16, 60, 36)), "plain drag moves both axes"); + // A near-diagonal ⇧-drag locks to 45°, as in Photoshop: (13,11) projects onto (12,12). + drag_shift(&mut app, [50.0, 26.0], [63.0, 37.0]); + assert_eq!(sel(&app), Some(Rect::new(52, 28, 72, 48)), "diagonal drag locks to 45°"); } -/// #6231: ⇧ also locks a ⌘-drag of the selected pixels to the dominant axis. +/// #6231: ⇧ also locks a ⌘-drag of the selected pixels to multiples of 45°. #[test] fn shift_constrains_a_floating_selection_drag() { use crate::canvas::{ToolEvent, tool_event}; @@ -684,8 +687,8 @@ fn shift_constrains_a_floating_selection_drag() { let offset = |app: &PhotocraftApp| photocraft_engine::float_cmds::floating(app.session.active().unwrap()).map(|f| f.offset); let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, Modifiers::COMMAND); - tool_event(&mut app, ToolEvent::Move { x: 34.0, y: 28.0, pressure: 1.0 }, cmd_shift); - tool_event(&mut app, ToolEvent::Up { x: 34.0, y: 28.0 }, cmd_shift); + tool_event(&mut app, ToolEvent::Move { x: 34.0, y: 22.0, pressure: 1.0 }, cmd_shift); + tool_event(&mut app, ToolEvent::Up { x: 34.0, y: 22.0 }, cmd_shift); assert_eq!(offset(&app), Some((14, 0)), "locked to the horizontal axis"); app.run("select.drop", json!({})).unwrap(); } diff --git a/crates/ui-egui/src/move_mods.rs b/crates/ui-egui/src/move_mods.rs index 24457f52d2..42ea6aa8af 100644 --- a/crates/ui-egui/src/move_mods.rs +++ b/crates/ui-egui/src/move_mods.rs @@ -152,18 +152,19 @@ fn drag_to(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; [start[0] + d[0], start[1] + d[1]] } -/// ⇧ while a selection tool drags the selection (#6231): lock the move to the dominant axis, as -/// the Move tool's layer drags do. The caller skips this when [`filter_event`] has already +/// ⇧ while a selection tool drags the selection (#6231): lock the move to multiples of 45°, as +/// Photoshop does ("Move a selection border") and as the Move tool's drags of selected pixels do +/// ([`TRANSFORM_DIRECTIONS`]). The caller skips this when [`filter_event`] has already /// rewritten the event (the Move tool itself); it covers the `sel_move` drags the marquee and /// lasso tools start, including ⌘-drags of the selected pixels. The locked offset is kept on the -/// drag between events so the axis doesn't flicker near 45°. +/// drag between events so the direction doesn't flicker between two neighbours. pub(crate) fn selection_drag_to(d: &mut crate::canvas::Drag, p: [f64; 2], shift: bool) -> [f64; 2] { if d.sel_move.is_none() || !shift { d.sel_axis = None; return p; } let delta = [p[0] - d.start[0], p[1] - d.start[1]]; - let locked = constrain(delta, d.sel_axis, MOVE_DIRECTIONS); + let locked = constrain(delta, d.sel_axis, TRANSFORM_DIRECTIONS); d.sel_axis = Some(locked); [d.start[0] + locked[0], d.start[1] + locked[1]] }