mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
289 lines
14 KiB
Rust
289 lines
14 KiB
Rust
use photocraft_geom::Rect;
|
||
use serde_json::json;
|
||
|
||
use super::*;
|
||
|
||
/// An 80×60 transparent document whose layer is painted red over (10..30)²; (10..30)² selected.
|
||
fn session() -> (Session, LayerId) {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 80, "height": 60, "background": "transparent"})).unwrap();
|
||
let id = s.active().unwrap().active_layer.unwrap();
|
||
s.edit("paint", |doc, _| {
|
||
doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||
(s, id)
|
||
}
|
||
|
||
fn alpha(d: &Document, id: LayerId, x: i32, y: i32) -> f32 {
|
||
d.layer(id).unwrap().surface().unwrap().rgba(x, y)[3]
|
||
}
|
||
|
||
#[test]
|
||
fn float_moves_a_cut_piece_without_touching_the_document_until_dropped() {
|
||
let (mut s, id) = session();
|
||
let steps = s.active().unwrap().history.past_len();
|
||
assert_eq!(s.execute("select.float", json!({"dx": 15, "dy": 0})).unwrap()["offset"], json!([15, 0]));
|
||
// Moving it again doesn't cut anything new.
|
||
s.execute("select.float", json!({"dx": 0, "dy": 10})).unwrap();
|
||
let st = s.active().unwrap();
|
||
assert_eq!(floating(st).unwrap().offset, (15, 10));
|
||
assert_eq!(alpha(&st.doc, id, 12, 12), 1.0, "the document is untouched while floating");
|
||
assert_eq!(st.history.past_len(), steps);
|
||
let shown = displayed(st, (0, 0)).unwrap();
|
||
assert!(alpha(&shown, id, 12, 12) == 0.0 && alpha(&shown, id, 27, 22) == 1.0, "shown cut and moved");
|
||
// Drop: one history step, pixels and selection where they were shown.
|
||
s.execute("select.drop", json!({})).unwrap();
|
||
let st = s.active().unwrap();
|
||
assert!(floating(st).is_none());
|
||
assert_eq!(st.history.past_len(), steps + 1);
|
||
for (x, y) in [(12, 12), (27, 22), (44, 39), (40, 40)] {
|
||
assert_eq!(alpha(&st.doc, id, x, y), alpha(&shown, id, x, y), "dropped as shown at ({x}, {y})");
|
||
}
|
||
assert_eq!(st.doc.selection.as_ref().unwrap().content_bounds(), Rect::new(25, 20, 45, 40));
|
||
// Undo takes the whole move back.
|
||
s.undo();
|
||
assert_eq!(alpha(&s.active().unwrap().doc, id, 12, 12), 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn any_other_command_drops_it_and_undo_puts_it_back() {
|
||
let (mut s, id) = session();
|
||
let steps = s.active().unwrap().history.past_len();
|
||
s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap();
|
||
// Undo while floating: the piece goes back; nothing in the document or history changed.
|
||
assert_eq!(s.execute("edit.undo", json!({})).unwrap()["floating"], json!("returned"));
|
||
let st = s.active().unwrap();
|
||
assert!(floating(st).is_none() && alpha(&st.doc, id, 12, 12) == 1.0 && st.history.past_len() == steps);
|
||
// Float again, then deselect: dropped first (where it was shown), then deselected.
|
||
s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap();
|
||
s.execute("select.deselect", json!({})).unwrap();
|
||
let st = s.active().unwrap();
|
||
assert!(st.doc.selection.is_none() && floating(st).is_none());
|
||
assert!(alpha(&st.doc, id, 12, 12) == 0.0 && alpha(&st.doc, id, 35, 20) == 1.0);
|
||
}
|
||
|
||
/// The History panel's Step Backward / Step Forward treat a floating piece like Edit › Undo /
|
||
/// Redo: no Move step is recorded and the redo states survive.
|
||
#[test]
|
||
fn history_panel_steps_put_the_piece_back_and_keep_the_redo_states() {
|
||
for back in ["edit.undo", "history.stepBackward"] {
|
||
let (mut s, id) = session();
|
||
let steps = s.active().unwrap().history.past_len();
|
||
s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap();
|
||
assert_eq!(s.execute(back, json!({})).unwrap()["floating"], json!("returned"), "{back}");
|
||
let st = s.active().unwrap();
|
||
assert!(floating(st).is_none() && alpha(&st.doc, id, 12, 12) == 1.0, "{back}: piece back");
|
||
assert_eq!((st.history.past_len(), st.history.can_redo()), (steps, false), "{back}: no Move step");
|
||
}
|
||
for forward in ["edit.redo", "history.stepForward"] {
|
||
let (mut s, id) = session();
|
||
// A redo state (the deselection), then a moved piece over the selection undo restored.
|
||
s.execute("select.deselect", json!({})).unwrap();
|
||
s.undo();
|
||
let steps = s.active().unwrap().history.past_len();
|
||
s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap();
|
||
s.execute(forward, json!({})).unwrap();
|
||
let st = s.active().unwrap();
|
||
assert!(floating(st).is_none(), "{forward}: piece back");
|
||
assert_eq!(alpha(&st.doc, id, 12, 12), 1.0, "{forward}: the move never reached the document");
|
||
assert!(st.doc.selection.is_none(), "{forward}: stepped forward to the deselection");
|
||
assert_eq!((st.history.past_len(), st.history.can_redo()), (steps + 1, false), "{forward}: no Move step");
|
||
// Nothing to step forward to: refused, and the piece keeps floating.
|
||
let (mut s, _) = session();
|
||
s.execute("select.float", json!({"dx": 20, "dy": 0})).unwrap();
|
||
let steps = s.active().unwrap().history.past_len();
|
||
assert!(s.execute(forward, json!({})).is_err(), "{forward}: nothing to redo");
|
||
let st = s.active().unwrap();
|
||
assert_eq!(floating(st).map(|f| f.offset), Some((20, 0)), "{forward}: still floating");
|
||
assert_eq!(st.history.past_len(), steps);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn needs_a_selection_and_an_unlocked_pixel_layer() {
|
||
let mut s = Session::new();
|
||
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'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`
|
||
#[test]
|
||
#[ignore]
|
||
fn float_drag_bench() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 6000, "height": 4000, "background": "transparent"})).unwrap();
|
||
s.execute("layer.new.layer", json!({})).unwrap();
|
||
s.edit("paint", |doc, a| {
|
||
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 6000, 4000), &[0.2, 0.4, 0.6, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
s.execute("select.rect", json!({"x": 1000, "y": 1000, "width": 500, "height": 400})).unwrap();
|
||
let t = std::time::Instant::now();
|
||
s.execute("select.float", json!({})).unwrap();
|
||
let cut = t.elapsed();
|
||
let t = std::time::Instant::now();
|
||
for i in 0..20 {
|
||
displayed(s.active().unwrap(), (i * 7, i * 3)).unwrap();
|
||
}
|
||
eprintln!("6000×4000 layer, 500×400 piece: cut {cut:?}, then {:?} per pointer move", t.elapsed() / 20);
|
||
}
|
||
|
||
#[test]
|
||
fn copy_floats_a_duplicate_and_leaves_the_original() {
|
||
let (mut s, id) = session();
|
||
s.execute("select.float", json!({"dx": 30, "dy": 0, "copy": true})).unwrap();
|
||
let shown = displayed(s.active().unwrap(), (0, 0)).unwrap();
|
||
assert!(alpha(&shown, id, 12, 12) == 1.0 && alpha(&shown, id, 42, 12) == 1.0, "original kept, copy shown");
|
||
s.execute("select.drop", json!({})).unwrap();
|
||
let doc = &s.active().unwrap().doc;
|
||
assert!(alpha(doc, id, 12, 12) == 1.0 && alpha(doc, id, 42, 12) == 1.0, "dropped: both");
|
||
}
|
||
|
||
#[test]
|
||
fn float_with_the_mask_target_moves_the_mask_not_the_layer() {
|
||
let (mut s, id) = session();
|
||
s.edit("mask", |doc, _| {
|
||
let l = doc.layer_mut(id).unwrap();
|
||
let mut m = photocraft_doc::LayerMask::reveal_all();
|
||
m.surface.fill_rect(Rect::new(10, 10, 30, 30), &[0.0]);
|
||
l.mask = Some(m);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let mask_at = |s: &Session, x, y| s.active().unwrap().doc.layer(id).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
|
||
s.execute("select.float", json!({"dx": 25, "dy": 0, "copy": true, "target": "mask"})).unwrap();
|
||
s.execute("select.drop", json!({})).unwrap();
|
||
assert_eq!(mask_at(&s, 12, 12), 0.0, "the copy leaves the mask where it was");
|
||
assert_eq!(mask_at(&s, 37, 12), 0.0, "the mask's selected part moved");
|
||
assert_eq!(mask_at(&s, 60, 12), 1.0);
|
||
assert_eq!(alpha(&s.active().unwrap().doc, id, 37, 12), 0.0, "the layer's pixels did not move");
|
||
assert_eq!(alpha(&s.active().unwrap().doc, id, 12, 12), 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn cutting_an_untouched_part_of_the_mask_moves_it_and_leaves_the_background_colour() {
|
||
let (mut s, id) = session();
|
||
s.edit("mask", |doc, _| {
|
||
let mut m = photocraft_doc::LayerMask::hide_all();
|
||
m.surface.fill_rect(Rect::new(50, 10, 70, 30), &[1.0]);
|
||
doc.layer_mut(id).unwrap().mask = Some(m);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
s.tools.background = [0.5, 0.5, 0.5, 1.0];
|
||
let mask_at = |s: &Session, x, y| s.active().unwrap().doc.layer(id).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
|
||
// (10..30)² is the mask's untouched black; it moves over the white at (50..70, 10..30).
|
||
s.execute("select.float", json!({"dx": 40, "dy": 0, "target": "mask"})).unwrap();
|
||
s.execute("select.drop", json!({})).unwrap();
|
||
assert_eq!(mask_at(&s, 55, 12), 0.0, "the untouched black moved");
|
||
assert!((mask_at(&s, 12, 12) - 0.5).abs() < 0.01, "the hole takes the background colour");
|
||
assert_eq!(mask_at(&s, 40, 12), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn float_shares_interior_tiles_on_cut_and_place() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap();
|
||
s.tools.background = [0.2, 0.4, 0.8, 1.0];
|
||
let id = s.active().unwrap().active_layer.unwrap();
|
||
// Paint non-uniform pixels so original tiles are distinct.
|
||
s.edit("pattern", |doc, _| {
|
||
let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap();
|
||
for ty in 0..4 {
|
||
for tx in 0..4 {
|
||
surf.write_pixel(tx * 256 + 5, ty * 256 + 5, &[tx as f32 * 0.25, ty as f32 * 0.25, 0.5, 1.0]);
|
||
}
|
||
}
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
// Select (100..900)^2, which fully covers interior tiles (1, 1) and (2, 2).
|
||
s.execute("select.rect", json!({"x": 100, "y": 100, "width": 800, "height": 800})).unwrap();
|
||
s.execute("select.float", json!({})).unwrap();
|
||
let st = s.active().unwrap();
|
||
let orig = st.doc.layer(id).unwrap().surface().unwrap();
|
||
let parts = &floating(st).unwrap().parts;
|
||
let tc1 = photocraft_geom::TileCoord { tx: 1, ty: 1 };
|
||
let tc2 = photocraft_geom::TileCoord { tx: 2, ty: 2 };
|
||
// Interior tiles of `piece` share the original layer's Arc<Tile>.
|
||
assert!(Arc::ptr_eq(orig.tile(tc1).unwrap(), parts.piece.tile(tc1).unwrap()));
|
||
assert!(Arc::ptr_eq(orig.tile(tc2).unwrap(), parts.piece.tile(tc2).unwrap()));
|
||
// Interior tiles of the Background hole in `rest` share one solid fill Arc<Tile>.
|
||
assert!(Arc::ptr_eq(parts.rest.tile(tc1).unwrap(), parts.rest.tile(tc2).unwrap()));
|
||
// Moving by a whole tile (256, 0) shares the interior tile directly in the preview document.
|
||
let shown = displayed(st, (256, 0)).unwrap();
|
||
let shown_surf = shown.layer(id).unwrap().surface().unwrap();
|
||
let moved_tc = photocraft_geom::TileCoord { tx: 2, ty: 1 };
|
||
assert!(Arc::ptr_eq(orig.tile(tc1).unwrap(), shown_surf.tile(moved_tc).unwrap()));
|
||
}
|
||
|
||
#[test]
|
||
fn float_feathered_selection_preserves_partial_alpha_across_depths() {
|
||
for depth in [8, 16, 32] {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 120, "height": 80, "background": "transparent", "depth": depth})).unwrap();
|
||
let id = s.active().unwrap().active_layer.unwrap();
|
||
s.edit("paint", |doc, _| {
|
||
doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 55, 80), &[1.0, 0.0, 0.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
s.execute("select.rect", json!({"x": 20, "y": 20, "width": 30, "height": 30, "feather": 4.0})).unwrap();
|
||
s.execute("select.float", json!({"dx": 40, "dy": 0})).unwrap();
|
||
s.execute("select.drop", json!({})).unwrap();
|
||
let surf = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap();
|
||
// Center of moved piece is opaque red; center of hole is transparent; feathered edge has partial alpha.
|
||
assert!(surf.rgba(75, 35)[3] > 0.95, "{depth}-bit moved center");
|
||
assert!(surf.rgba(35, 35)[3] < 0.05, "{depth}-bit hole center");
|
||
let edge_a = surf.rgba(60, 35)[3];
|
||
assert!(edge_a > 0.1 && edge_a < 0.9, "{depth}-bit feathered edge alpha={edge_a}");
|
||
}
|
||
}
|