Merge PR #6254: Selection: ⇧ mid-drag locks a selection-tool move to the axis (#6231)

This commit is contained in:
David Mann
2026-10-22 23:40:21 +02:00
3 changed files with 63 additions and 0 deletions
+8
View File
@@ -269,6 +269,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<bool>,
/// The last ⇧-locked offset of a selection-tool drag of the selection (`move_mods`, #6231);
/// 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<crate::lasso_ui::Lasso>,
}
@@ -286,6 +289,7 @@ impl Drag {
released: egui::Modifiers::NONE,
reposition: false,
sel_move: None,
sel_axis: None,
lasso: None,
}
}
@@ -5795,6 +5799,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 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);
// 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)
@@ -6226,6 +6233,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) {
+38
View File
@@ -655,6 +655,44 @@ 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 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};
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");
// 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 multiples of 45°.
#[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: 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();
}
/// ⌘-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]
+17
View File
@@ -152,6 +152,23 @@ 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 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 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, TRANSFORM_DIRECTIONS);
d.sel_axis = Some(locked);
[d.start[0] + locked[0], d.start[1] + locked[1]]
}
/// The Move drag in progress is an ⌥-drag that has duplicated the layers (it now moves the copies).
pub(crate) fn duplicated(app: &PhotocraftApp) -> bool {
moving(app) && app.move_mods.dup_from.is_some()