Compare commits

...
Author SHA1 Message Date
Claude 1941e24b8b Pipette cursor test: Windows draws the precise crosshair itself
input_tests::eyedropper_and_alt_sampling_show_a_pipette (#1028) expected
the OS crosshair with Other Cursors = Precise, but on Windows the canvas
draws its own crosshair and hides the OS cursor (visible_crosshair), so
the test failed on every Windows run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-08 21:34:28 +00:00
Claude 14da1349a3 Merge main into issue-578-move-selected-pixels
# Conflicts:
#	crates/engine/src/float_cmds.rs
2026-10-08 20:14:07 +00:00
Claude 8c0a78f58d Merge main into issue-578-move-selected-pixels (main's automation test fixed in #1074) 2026-10-08 18:21:03 +00:00
bflatasticandClaude Opus 5.5 51da0cfc8f Move tool moves the selected pixels when there is a selection (#578)
With a selection, a Move-tool drag (from anywhere) or arrow-key nudge now
floats the active layer's selected pixels (select.float) instead of moving
the whole layer: the hole is transparent, or the background colour on the
Background layer; Alt copies the pixels instead of duplicating the layer;
Shift constrains the drag to 45 degrees; Enter, Deselect or any other
command drops the piece as one "Move" history step. Layers whose pixels
can't float (type, shape, smart object, group, position-locked, hidden, or
several selected layers) and Auto-Select without a selection behave as
before. select.float gains "copy"; a click that moves nothing no longer
leaves a floating piece behind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 12:19:24 +07:00
9 changed files with 277 additions and 24 deletions

No files matched your search

+19 -10
View File
@@ -1,9 +1,10 @@
//! Floating selection: the selected pixels of a layer cut out and moved as a piece, without
//! touching the document until the piece is dropped.
//!
//! - `select.float {"dx","dy"}` cuts the selected pixels of the active layer into a floating piece
//! (the first time) and moves it by whole pixels. Further calls move the same piece; nothing new
//! is cut.
//! - `select.float {"dx","dy","copy"}` cuts the selected pixels of the active layer into a floating
//! piece (the first time; `copy` leaves them and floats a duplicate) and moves it by whole
//! pixels. Further calls move the same piece; nothing new is cut. The Move tool, the marquee and
//! lasso ⌘-drags and the Move tool's arrow keys all move selected pixels this way.
//! - `select.drop` drops the piece into its layer and moves the selection with it, as one history
//! step.
//! - Any other command drops the piece first (so it lands exactly where it was shown), except Undo,
@@ -34,8 +35,10 @@ pub struct CutParts {
impl CutParts {
/// Split `layer` of `doc` by its selection. With `copy` the selected pixels stay in the layer
/// too (the piece is a duplicate). Errors without a selection or pixels.
pub fn new(doc: &Document, layer: LayerId, copy: bool) -> Result<Self> {
/// too (the piece is a duplicate). Without, they leave a transparent hole, or on the Background,
/// which can't hold transparency, one filled with `background` (Photoshop). Errors without a
/// selection or pixels.
pub fn new(doc: &Document, layer: LayerId, copy: bool, background: [f32; 4]) -> Result<Self> {
let sel = doc.selection.as_ref().ok_or_else(|| EngineError::Other("no selection".into()))?;
let l = doc.layer(layer).ok_or(EngineError::NoLayer(layer))?;
let LayerContent::Raster(surf) = &l.content else {
@@ -45,6 +48,7 @@ impl CutParts {
let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true);
let mut rest = if fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) };
let mut piece = Surface::new(with_alpha);
let fill = !copy && crate::extra_cmds::is_background(l);
let b = sel.content_bounds().intersect(&surf.content_bounds());
if !b.is_empty() {
let n = with_alpha.channels();
@@ -59,11 +63,14 @@ impl CutParts {
} else {
p[a] *= k;
}
if !copy {
if !copy && !fill {
r[a] *= 1.0 - k;
}
}
rest.write_region(b, &rp);
if fill {
crate::pixels::fill_surface(&mut rest, b, background, Some(sel), true);
}
rest.prune();
piece.write_region(b, &pp);
piece.prune();
@@ -124,7 +131,8 @@ fn can_float(s: &Session) -> std::result::Result<(), String> {
return Err("the active layer has no pixels to move".into());
}
let locks = st.doc.effective_locks(l.id);
if locks.all || locks.position {
// The Background is position-locked, but its selected pixels move (Photoshop).
if locks.all || (locks.position && !crate::extra_cmds::is_background(l)) {
return Err(format!("layer \"{}\" is locked", l.name));
}
Ok(())
@@ -133,10 +141,11 @@ fn can_float(s: &Session) -> std::result::Result<(), String> {
fn float(s: &mut Session, p: &Value) -> Result<Value> {
let (dx, dy) = (int_param(p, "dx"), int_param(p, "dy"));
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
let background = s.tools.background;
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
if floating(st).is_none() {
let layer = st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let parts = CutParts::new(&st.doc, layer, copy)?;
let parts = CutParts::new(&st.doc, layer, copy, background)?;
st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts) });
}
let f = st.floating.as_mut().ok_or(EngineError::NoDocument)?;
@@ -156,7 +165,7 @@ pub fn drop_floating(s: &mut Session) -> Result<Value> {
if (dx, dy) == (0, 0) {
return Ok(json!({"dropped": true, "offset": [0, 0]}));
}
s.edit("Move Selected Pixels", |doc, _| {
s.edit("Move", |doc, _| {
let moved = f.parts.moved(doc, dx, dy)?;
let surf = moved.layer(f.layer).and_then(|l| l.surface()).cloned().ok_or(EngineError::NoLayer(f.layer))?;
*doc.layer_mut(f.layer).ok_or(EngineError::NoLayer(f.layer))?.surface_mut().ok_or(EngineError::NoLayer(f.layer))? = surf;
@@ -196,7 +205,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Float Selection",
menu: &[],
shortcut: None,
params: r##"{"dx":px=0,"dy":px=0,"copy":bool=false} → {layer, offset} (cuts the selected pixels of the active layer into a floating piece the first time, or with copy leaves them and floats a duplicate; then moves it by whole pixels; dropped by select.drop or any other command, put back by edit.undo)"##,
params: r##"{"dx":px=0,"dy":px=0,"copy":bool=false} → {layer, offset} (cuts the selected pixels of the active layer into a floating piece the first time, or with copy leaves them and floats a duplicate; on the Background the hole takes the background colour; then moves it by whole pixels; dropped by select.drop or any other command, put back by edit.undo)"##,
enabled: can_float,
journal: true,
run: float,
+40 -1
View File
@@ -71,9 +71,48 @@ fn needs_a_selection_and_an_unlocked_pixel_layer() {
s.execute("file.new", json!({"width": 20, "height": 20})).unwrap();
assert!(!s.is_enabled("select.float"), "no selection");
s.execute("select.all", json!({})).unwrap();
assert!(!s.is_enabled("select.float"), "the Background is locked");
assert!(s.is_enabled("select.float"), "the Background's selected pixels move");
s.execute("layer.new.layer", json!({})).unwrap();
assert!(s.is_enabled("select.float"));
s.execute("layer.lockLayers", json!({"position": true})).unwrap();
assert!(!s.is_enabled("select.float"), "a position-locked layer");
}
/// On the Background, which can't hold transparency, the hole takes the background colour.
#[test]
fn on_the_background_the_hole_takes_the_background_colour() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 40})).unwrap();
s.tools.background = [0.0, 0.0, 1.0, 1.0];
let id = s.active().unwrap().active_layer.unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
s.execute("select.float", json!({"dx": 15, "dy": 0})).unwrap();
s.execute("select.drop", json!({})).unwrap();
let px = |x, y| s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().rgba(x, y);
assert_eq!(px(12, 12), [0.0, 0.0, 1.0, 1.0], "the hole");
assert_eq!(px(27, 12), [1.0, 1.0, 1.0, 1.0], "the moved pixels");
assert_eq!(px(5, 5), [1.0, 1.0, 1.0, 1.0], "the rest");
}
/// Copying leaves the original pixels, at every depth; the move is one undo step.
#[test]
fn a_copy_leaves_the_original_at_every_depth_in_one_undo_step() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 80, "height": 60, "background": "transparent", "depth": depth})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
let steps = s.active().unwrap().history.past_len();
s.execute("select.float", json!({"dx": 30, "dy": 0, "copy": true})).unwrap();
s.execute("select.drop", json!({})).unwrap();
let st = s.active().unwrap();
assert!(alpha(&st.doc, id, 12, 12) == 1.0 && alpha(&st.doc, id, 42, 12) == 1.0, "{depth}-bit: both");
assert_eq!((st.history.past_len(), st.history.undo_label()), (steps + 1, Some("Move")), "{depth}-bit");
s.undo();
let st = s.active().unwrap();
assert!(alpha(&st.doc, id, 12, 12) == 1.0 && alpha(&st.doc, id, 42, 12) == 0.0, "{depth}-bit: undone");
}
}
/// `cargo test --release -p photocraft-engine float_drag_bench -- --ignored --nocapture`
+23 -4
View File
@@ -50,7 +50,8 @@ pub struct Drag {
/// sizing it (marquees, lasso, shapes; `hold_keys`).
pub reposition: bool,
/// A marquee or lasso drag that started inside the selection moves it instead of drawing:
/// `Some(false)` moves the outline, `Some(true)` moves the floating piece (`select.float`).
/// `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`).
pub sel_move: Option<bool>,
pub lasso: Option<crate::lasso_ui::Lasso>,
}
@@ -2470,9 +2471,11 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
crate::transform_tool::end_if_left(app);
// View › Snap / Snap To and smart guides (snap_ui.rs).
let raw = ev;
// A press anywhere but on the floating piece (or with ⇧ / ⌥, to draw) drops it first.
// A press anywhere but on the floating piece (or with ⇧ / ⌥, to draw) drops it first; the
// Move tool drags it from anywhere.
if let ToolEvent::Down { x, y, .. } = raw
&& app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some())
&& !crate::move_ui::moves_selected_pixels(app)
&& selection_drag_kind(app, app.ui.tool, [x, y], mods) != Some(true)
{
let _ = app.run("select.drop", json!({}));
@@ -2544,11 +2547,21 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
// Move tool over a guide drags the guide (off the canvas deletes it).
match ev {
ToolEvent::Down { x, y, .. } if tool == Tool::Move => {
ToolEvent::Down { x, y, pressure } if tool == Tool::Move => {
if let Some((vertical, i)) = crate::rulers::guide_at(app, x, y) {
app.guide_drag = Some(crate::rulers::GuideDrag { vertical, index: Some(i), pos: if vertical { x } else { y } });
return;
}
// With a selection: cut the selected pixels (⌥ copies them) and drag them as a floating
// piece, from anywhere, as a marquee ⌘-drag does (no Auto-Select pick).
if crate::move_ui::moves_selected_pixels(app) {
if crate::move_ui::float_selected(app, mods.alt, 0.0, 0.0) {
let mut d = Drag::new(tool, [x, y], vec![[x, y, pressure as f64]], mods, false);
d.sel_move = Some(true);
app.drag = Some(d);
}
return;
}
// Auto-Select (or ⌘-click while it is off) picks the layer under the pointer first.
if app.ui.tool_options.move_auto_select != mods.command {
let target = app.ui.tool_options.move_target.clone();
@@ -2705,6 +2718,9 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
if d.tool == Tool::Move && d.points.len() < 2 && matches!(raw, ToolEvent::Up { x, y } if [x, y] == d.start) {
app.move_preview = None;
crate::move_mods::finish(app);
if d.sel_move.is_some() {
finish_selection_drag(app, true, d.start, d.start);
}
return;
}
if d.points.last().is_none_or(|p| p[0] != x || p[1] != y) {
@@ -2790,10 +2806,13 @@ fn selection_shown_offset(app: &PhotocraftApp) -> Option<(i32, i32)> {
}
/// End of a selection drag: move the outline, or the floating piece. A click without moving
/// deselects, like a marquee click (a click on a floating piece leaves it floating).
/// deselects, like a marquee click (a click on a floating piece leaves it floating, unless it never
/// moved: then it is put back, so Undo isn't spent on it).
fn finish_selection_drag(app: &mut PhotocraftApp, floating: bool, start: [f64; 2], end: [f64; 2]) {
let (dx, dy) = ((end[0] - start[0]).round(), (end[1] - start[1]).round());
let unmoved = app.session.active().and_then(photocraft_engine::float_cmds::floating).is_some_and(|f| f.offset == (0, 0));
let r = match (floating, dx == 0.0 && dy == 0.0) {
(true, true) if unmoved => app.run("select.drop", json!({})),
(true, true) => return,
(false, true) if app.session.is_enabled("select.deselect") => app.run("select.deselect", json!({})),
(false, true) => return,
+4 -2
View File
@@ -384,8 +384,10 @@ fn eyedropper_and_alt_sampling_show_a_pipette() {
h.event(egui::Event::ModifiersChanged(Modifiers::ALT));
assert_eq!(cursor(&mut h), egui::CursorIcon::None, "⌥ samples with a pipette");
h.state_mut().run("prefs.set", json!({"values": {"cursors.other": "precise"}})).unwrap();
assert_eq!(cursor(&mut h), egui::CursorIcon::Crosshair, "Precise keeps the crosshair");
// Windows draws the crosshair on the canvas and hides the OS cursor (`visible_crosshair`).
let crosshair = if cfg!(target_os = "windows") { egui::CursorIcon::None } else { egui::CursorIcon::Crosshair };
assert_eq!(cursor(&mut h), crosshair, "Precise keeps the crosshair");
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.state_mut().ui.tool = crate::state::Tool::Eyedropper;
assert_eq!(cursor(&mut h), egui::CursorIcon::Crosshair);
assert_eq!(cursor(&mut h), crosshair);
}
+1 -1
View File
@@ -276,7 +276,7 @@ fn cmd_drag_floats_the_selected_pixels() {
assert!(d.selection.is_none() && offset(&app).is_none());
assert!(alpha(&d, 12, 12) == 0.0 && alpha(&d, 26, 21) == 1.0 && alpha(&d, 44, 39) == 1.0);
let labels: Vec<String> = app.session.active().unwrap().history.entries().into_iter().skip(steps + 1).map(|e| e.to_string()).collect();
assert_eq!(labels.first().map(String::as_str), Some("Move Selected Pixels"), "{labels:?}");
assert_eq!(labels.first().map(String::as_str), Some("Move"), "{labels:?}");
}
/// Through the real canvas (mouse events): a drag inside the ants moves the selection.
+14 -4
View File
@@ -9,6 +9,8 @@
//! history step ("Duplicate + Move"). ⇧⌥ combines both.
//! - Arrow keys nudge the selected layers 1 px (⇧: 10 px); ⌥ duplicates first. While Free
//! Transform is active they nudge the box instead.
//! - With a selection the drag and the arrow keys move the selected pixels (`move_ui`): ⇧ locks
//! the drag to multiples of 45° and ⌥ copies the pixels instead of duplicating the layer.
//!
//! The hooks are small and local: [`filter_event`] rewrites pointer events before the Move tool
//! sees them and [`finish`] folds the history after the drag, so the Move drag pipeline itself is
@@ -22,7 +24,8 @@ use crate::state::Tool;
/// Directions ⇧ locks a Move-tool drag to: horizontal and vertical (Photoshop's Move tool).
pub const MOVE_DIRECTIONS: u32 = 4;
/// Directions ⇧ locks a Free Transform body drag to: the axes and the 45° diagonals.
/// Directions ⇧ locks a Free Transform body drag, or a Move-tool drag of selected pixels, to: the
/// axes and the 45° diagonals.
pub const TRANSFORM_DIRECTIONS: u32 = 8;
/// Extra angle (degrees) the pointer must pass the half-way line by before the locked axis
/// switches, so it doesn't flicker when dragging near the diagonal.
@@ -131,13 +134,15 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier
fn drag_to(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2] {
let Some(start) = app.move_mods.start.filter(|_| moving(app)) else { return p };
// Selected pixels (`move_ui`): ⌥ copied them at the press.
let pixels = app.drag.as_ref().is_some_and(|d| d.sel_move.is_some());
let mut d = [p[0] - start[0], p[1] - start[1]];
if mods.shift {
d = constrain(d, app.move_mods.last, MOVE_DIRECTIONS);
d = constrain(d, app.move_mods.last, if pixels { TRANSFORM_DIRECTIONS } else { MOVE_DIRECTIONS });
}
app.move_mods.last = Some(d);
let zoom = f64::from(app.current_zoom().max(0.01));
if app.move_mods.alt && app.move_mods.dup_from.is_none() && d[0].hypot(d[1]) * zoom >= DUPLICATE_THRESHOLD {
if app.move_mods.alt && !pixels && app.move_mods.dup_from.is_none() && d[0].hypot(d[1]) * zoom >= DUPLICATE_THRESHOLD {
app.move_mods.dup_from = duplicate(app);
// Only once per drag, even if duplicating failed.
app.move_mods.alt = false;
@@ -181,7 +186,8 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
false
}
/// Moves the selected layers (or the Free Transform box) by `(dx, dy)` pixels.
/// Moves the selected layers (or the Free Transform box, or the selected pixels) by `(dx, dy)`
/// pixels.
pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) {
if let Some(t) = app.ui.transform.as_mut() {
if t.warp.is_none() {
@@ -193,6 +199,10 @@ pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) {
if app.drag.is_some() {
return;
}
if crate::move_ui::moves_selected_pixels(app) {
crate::move_ui::float_selected(app, duplicate_first, dx, dy);
return;
}
let from = if duplicate_first { duplicate(app) } else { None };
if let Err(e) = app.run("layer.translate", json!({"dx": dx, "dy": dy})) {
app.ui.status = e;
+158 -1
View File
@@ -2,15 +2,50 @@
//! the moving layers already at the pointer, exactly as `layer.translate` will leave them, and
//! recomposites only where they were and where they are. Releasing commits one `layer.translate`
//! (one history step) whose damage rect refreshes the same area.
//!
//! With a selection, the Move tool drags the selected pixels instead (#578): they float
//! (`select.float`, the canvas shows the piece at the pointer) until ↩, Deselect or any other
//! command drops them, as one "Move" history step.
use std::sync::Arc;
use photocraft_doc::{DocId, Document, LayerId};
use photocraft_geom::Rect;
use serde_json::json;
use crate::PhotocraftApp;
use crate::state::Tool;
/// Does the Move tool drag (and do the arrow keys nudge) the selected pixels of the active layer
/// rather than the whole layer? With a selection, as in Photoshop; a layer whose pixels can't
/// float (type, shape, Smart Object, group, position-locked), a hidden layer or several selected
/// layers still move whole, as without one.
pub(crate) fn moves_selected_pixels(app: &PhotocraftApp) -> bool {
let Some(st) = app.session.active() else { return false };
app.ui.tool == Tool::Move
&& app.ui.transform.is_none()
&& st.doc.selection.is_some()
&& st.selected_layers().len() <= 1
&& st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.visible)
&& app.session.is_enabled("select.float")
}
/// Floats the selected pixels and moves them by (dx, dy) (`select.float`): cuts them the first
/// time, or with `copy` (⌥) floats a duplicate and leaves them; `copy` on a piece already floating
/// drops it where it is and floats a copy of it. False when it failed (reported in the status bar).
pub(crate) fn float_selected(app: &mut PhotocraftApp, copy: bool, dx: f64, dy: f64) -> bool {
let floating = app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some());
let dropped = if copy && floating { app.run("select.drop", json!({})) } else { Ok(json!(null)) };
match dropped.and_then(|_| app.run("select.float", json!({"dx": dx, "dy": dy, "copy": copy}))) {
Ok(_) => true,
Err(e) => {
app.ui.status = e;
app.ui.status_error = true;
false
}
}
}
/// Preview keys of move drags: `BASE + n`, one per offset shown (see `canvas::display_doc`).
const BASE: u64 = 1 << 39;
@@ -185,7 +220,7 @@ pub(crate) fn finish(app: &mut PhotocraftApp, dx: f64, dy: f64) {
if dx == 0.0 && dy == 0.0 {
return;
}
if app.run("layer.translate", serde_json::json!({"dx": dx, "dy": dy})).is_err() {
if app.run("layer.translate", json!({"dx": dx, "dy": dy})).is_err() {
return;
}
let Some(p) = shown else { return };
@@ -253,4 +288,126 @@ mod tests {
assert!(live(false), "by default the layer follows the pointer");
assert!(!live(true), "with the preference on, only the outline and arrow move");
}
/// A `depth`-bit transparent document whose layer holds a red square (8..24)² and a blue one
/// (40..56)×(8..24), the red one selected; the Move tool with Auto-Select on.
fn selected_pixels(depth: u32) -> (PhotocraftApp, photocraft_doc::LayerId) {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 64, "height": 64, "background": "transparent", "depth": depth})).unwrap();
app.sync_views();
for (x, color) in [(40, "#0000ff"), (8, "#ff0000")] {
app.session.execute("select.rect", json!({"x": x, "y": 8, "width": 16, "height": 16})).unwrap();
app.session.execute("edit.fill", json!({"color": color})).unwrap();
}
app.ui.tool = Tool::Move;
app.ui.tool_options.move_auto_select = true;
app.ui.extras.snap = false;
app.ui.view.show.smart_guides = false;
let id = app.session.active().unwrap().active_layer.unwrap();
(app, id)
}
fn alpha(app: &PhotocraftApp, id: photocraft_doc::LayerId, x: i32, y: i32) -> f32 {
app.session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().rgba(x, y)[3]
}
fn offset(app: &PhotocraftApp) -> Option<(i32, i32)> {
photocraft_engine::float_cmds::floating(app.session.active().unwrap()).map(|f| f.offset)
}
fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], m: egui::Modifiers) {
crate::canvas::tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, m);
crate::canvas::tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, m);
crate::canvas::tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, m);
}
/// ↩ through the app's key handling.
fn enter(app: &mut PhotocraftApp) {
let ctx = egui::Context::default();
let key = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
let input = egui::RawInput { events: vec![key], ..Default::default() };
ctx.run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx())).textures_delta.clear();
}
/// #578: with a selection the Move tool drags the selected pixels (from anywhere, without an
/// Auto-Select pick), not the layer; ↩ drops them as one "Move" step that one undo takes back.
#[test]
fn with_a_selection_the_move_tool_moves_only_the_selected_pixels() {
for depth in [8, 16, 32] {
let (mut app, id) = selected_pixels(depth);
let steps = app.session.active().unwrap().history.past_len();
drag(&mut app, [50.0, 40.0], [60.0, 45.0], egui::Modifiers::NONE);
assert_eq!(offset(&app), Some((10, 5)), "{depth}-bit: floating");
let shown = super::display_doc(&mut app, 0).unwrap().0;
let shown_alpha = |x, y| shown.layer(id).unwrap().surface().unwrap().rgba(x, y)[3];
assert!(shown_alpha(10, 10) == 0.0 && shown_alpha(30, 25) == 1.0, "{depth}-bit: shown moved");
enter(&mut app);
assert_eq!(offset(&app), None, "{depth}-bit: ↩ dropped it");
assert_eq!(alpha(&app, id, 10, 10), 0.0, "{depth}-bit: the hole is transparent");
assert_eq!(alpha(&app, id, 30, 25), 1.0, "{depth}-bit: the red square moved");
assert_eq!(alpha(&app, id, 48, 16), 1.0, "{depth}-bit: the unselected blue one stayed");
let st = app.session.active().unwrap();
assert_eq!(st.doc.selection.as_ref().unwrap().content_bounds(), Rect::new(18, 13, 34, 29), "{depth}-bit");
assert_eq!((st.history.past_len(), st.history.undo_label()), (steps + 1, Some("Move")), "{depth}-bit");
assert!(app.session.undo());
assert!(alpha(&app, id, 10, 10) == 1.0 && alpha(&app, id, 30, 25) == 0.0, "{depth}-bit: undone");
}
}
/// ⌥ copies the selected pixels (no layer copy), ⇧ locks the drag to multiples of 45°.
#[test]
fn alt_copies_and_shift_constrains_the_selected_pixels() {
for depth in [8, 16, 32] {
let (mut app, id) = selected_pixels(depth);
let layers = app.session.active().unwrap().doc.layers.len();
drag(&mut app, [16.0, 16.0], [30.0, 28.0], egui::Modifiers::ALT | egui::Modifiers::SHIFT);
assert_eq!(offset(&app), Some((13, 13)), "{depth}-bit: 45°");
app.run("select.drop", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "{depth}-bit: no layer copy");
assert!(alpha(&app, id, 10, 10) == 1.0 && alpha(&app, id, 35, 35) == 1.0, "{depth}-bit: original and copy");
assert!(app.session.undo());
assert_eq!(alpha(&app, id, 35, 35), 0.0, "{depth}-bit: undone");
}
}
/// A click moves nothing and leaves nothing floating (Undo isn't spent putting it back).
#[test]
fn a_click_with_a_selection_moves_nothing() {
let (mut app, id) = selected_pixels(8);
let steps = app.session.active().unwrap().history.past_len();
drag(&mut app, [16.0, 16.0], [16.0, 16.0], egui::Modifiers::NONE);
assert_eq!(offset(&app), None);
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
assert!(alpha(&app, id, 10, 10) == 1.0 && alpha(&app, id, 48, 16) == 1.0);
}
#[test]
fn arrow_keys_nudge_the_selected_pixels() {
let (mut app, id) = selected_pixels(16);
crate::move_mods::nudge(&mut app, 10.0, 0.0, false);
crate::move_mods::nudge(&mut app, 0.0, 1.0, false);
assert_eq!(offset(&app), Some((10, 1)));
// ⌥ leaves the piece where it is and nudges a copy of it.
crate::move_mods::nudge(&mut app, 1.0, 0.0, true);
assert_eq!(offset(&app), Some((1, 0)));
app.run("select.drop", json!({})).unwrap();
assert!(alpha(&app, id, 10, 10) == 0.0 && alpha(&app, id, 18, 20) == 1.0 && alpha(&app, id, 34, 20) == 1.0);
assert_eq!(alpha(&app, id, 48, 16), 1.0, "the layer itself didn't move");
}
/// Without floatable pixels (a position-locked layer) the Move tool behaves as without a
/// selection: it moves the layer, which the lock refuses.
#[test]
fn a_layer_that_cannot_float_moves_whole_as_before() {
let (mut app, id) = selected_pixels(8);
app.session.execute("layer.lockLayers", json!({"position": true})).unwrap();
drag(&mut app, [16.0, 16.0], [26.0, 16.0], egui::Modifiers::NONE);
assert_eq!(offset(&app), None);
assert!(alpha(&app, id, 10, 10) == 1.0 && alpha(&app, id, 30, 16) == 0.0, "nothing moved");
// Without a selection the layer moves whole.
app.session.execute("layer.lockLayers", json!({"position": false})).unwrap();
app.session.execute("select.deselect", json!({})).unwrap();
drag(&mut app, [16.0, 16.0], [26.0, 16.0], egui::Modifiers::NONE);
assert!(alpha(&app, id, 10, 10) == 0.0 && alpha(&app, id, 58, 16) == 1.0, "the whole layer moved");
}
}
+11
View File
@@ -280,6 +280,17 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
if focus == Focus::None && crate::move_mods::arrow_keys(app, ctx) {
return;
}
// Moved pixels still floating: ↩ drops them.
if focus == Focus::None
&& app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some())
&& ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter))
{
if let Err(e) = app.run("select.drop", json!({})) {
app.ui.status = e;
app.ui.status_error = true;
}
return;
}
// Pen path in progress: ↩ finishes (open path), Esc cancels.
if app.ui.pen.is_some() {
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) {
+7 -1
View File
@@ -74,9 +74,15 @@ fn build(app: &PhotocraftApp, exclude: &[LayerId], smart: bool) -> SnapTargets {
if smart { t.filtered(SnapKind::is_smart) } else { t }
}
/// Union of the selected layers' bounds (what the Move tool drags).
/// What the Move tool drags: the selection's bounds where its pixels float (`move_ui`), else the
/// union of the selected layers' bounds.
fn moving_rect(app: &PhotocraftApp) -> Option<[f64; 4]> {
let st = app.session.active()?;
if crate::move_ui::moves_selected_pixels(app) {
let (dx, dy) = photocraft_engine::float_cmds::floating(st).map_or((0, 0), |f| f.offset);
let r = st.doc.selection.as_ref()?.content_bounds().translate(dx, dy);
return (!r.is_empty()).then(|| [r.x0, r.y0, r.x1, r.y1].map(f64::from));
}
union(st.selected_layers().into_iter().filter_map(|id| layer_rect(&st.doc, id)))
}