mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 23:24:07 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1941e24b8b | ||
|
|
14da1349a3 | ||
|
|
8c0a78f58d | ||
|
|
51da0cfc8f |
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user