mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
Merge pull request #10059 from tomgardenart/fix/selected-pixel-transfer-nostella
Drag selected pixels between document tabs and tiled canvases
This commit is contained in:
10 files changed
+413
-18
No files matched your search
@@ -325,3 +325,7 @@ sync_github_name = true
|
||||
[people.rmsff]
|
||||
real_name = "Serikov Roman"
|
||||
sync_github_name = false
|
||||
|
||||
[people.tomgardenart]
|
||||
display_name = "Nostella"
|
||||
sync_github_name = false
|
||||
@@ -44,6 +44,16 @@ pub struct CutParts {
|
||||
}
|
||||
|
||||
impl CutParts {
|
||||
/// Selected pixels in source-document coordinates, including soft coverage.
|
||||
pub(crate) fn selected_pixels(&self) -> &Surface {
|
||||
&self.piece
|
||||
}
|
||||
|
||||
/// Whether the floating piece contains a layer mask rather than layer pixels.
|
||||
pub(crate) fn is_mask(&self) -> bool {
|
||||
self.mask
|
||||
}
|
||||
|
||||
/// Split `layer` of `doc` by its selection. With `copy` the selected pixels stay in the layer
|
||||
/// 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
|
||||
@@ -340,7 +350,7 @@ pub fn drop_floating(s: &mut Session) -> Result<Value> {
|
||||
|
||||
/// Commands that leave a floating piece floating: its own, and ones that don't touch documents.
|
||||
fn keeps_floating(id: &str) -> bool {
|
||||
matches!(id, "select.float" | "select.drop" | "brush.mixer.sample" | "brush.mixer.load" | "brush.mixer.clean")
|
||||
matches!(id, "select.float" | "select.drop" | "layer.copyToDocument" | "brush.mixer.sample" | "brush.mixer.load" | "brush.mixer.clean")
|
||||
|| ["view.", "window.", "help.", "prefs."].iter().any(|p| id.starts_with(p))
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (from, to) = (&s.documents()[src], &s.documents()[dest]);
|
||||
let (sdoc, ddoc) = (from.doc.clone(), to.doc.clone());
|
||||
let floating = crate::float_cmds::floating(from).cloned();
|
||||
let ids: Vec<LayerId> = match p.get("layers") {
|
||||
None => from.selected_layers(),
|
||||
Some(v) => v
|
||||
@@ -236,6 +237,14 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad("no layers to copy"));
|
||||
}
|
||||
let selection_only = match p.get("selectionOnly") {
|
||||
None => false,
|
||||
Some(Value::Bool(on)) => *on,
|
||||
Some(_) => return Err(bad("`selectionOnly` must be a boolean")),
|
||||
};
|
||||
if selection_only && (ids.len() != 1 || sdoc.selection.is_none()) {
|
||||
return Err(bad("copying selected pixels requires a selection and one pixel layer"));
|
||||
}
|
||||
for (doc, role) in [(&sdoc, "source"), (&ddoc, "destination")] {
|
||||
if !layered(doc.mode) {
|
||||
return Err(EngineError::Other(format!("layers can't be copied with a {:?} {role} document", doc.mode)));
|
||||
@@ -250,7 +259,24 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
copies.text_styles.clone_from(&sdoc.text_styles);
|
||||
for id in &ids {
|
||||
let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
|
||||
let mut l = src_layer.duplicate();
|
||||
let mut l = if selection_only {
|
||||
let parts = match floating.as_ref().filter(|f| f.layer == *id) {
|
||||
Some(f) if f.parts.is_mask() => return Err(bad("a floating layer mask cannot be transferred as selected pixels")),
|
||||
Some(f) => f.parts.clone(),
|
||||
None => std::sync::Arc::new(crate::float_cmds::CutParts::new(&sdoc, *id, true, s.tools.background, false)?),
|
||||
};
|
||||
if parts.selected_pixels().content_bounds().is_empty() {
|
||||
return Err(bad("the selected pixels are empty"));
|
||||
}
|
||||
let mut layer = Layer::new(src_layer.name.clone(), LayerContent::Raster(parts.selected_pixels().clone()));
|
||||
layer.opacity = src_layer.opacity;
|
||||
if let Some(f) = floating.as_ref().filter(|f| f.layer == *id) {
|
||||
crate::commands::translate_layer(&sdoc, &mut layer, f.offset.0, f.offset.1);
|
||||
}
|
||||
layer
|
||||
} else {
|
||||
src_layer.duplicate()
|
||||
};
|
||||
// A dragged Background arrives as an ordinary layer (the destination keeps its own).
|
||||
if sdoc.is_background(*id) {
|
||||
l.name = ddoc.next_layer_name("Layer");
|
||||
@@ -399,7 +425,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Copy Layers to Document",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"document":index (destination),"layers":[id,…]?=the source's selected layers,"source":index?=active,"center":bool=false (centre on the destination's canvas),"at":[x,y]? (centre the copies on this point),"offset":[dx,dy]?=[0,0] (from their place in the source),"name":str? (one layer: the copy's name)}"##,
|
||||
params: r##"{"document":index (destination),"layers":[id,…]?=the source's selected layers,"source":index?=active,"selectionOnly":bool=false (copy selected pixels from one raster layer),"center":bool=false (centre on the destination's canvas),"at":[x,y]? (centre the copies on this point),"offset":[dx,dy]?=[0,0] (from their place in the source),"name":str? (one layer: the copy's name)}"##,
|
||||
enabled,
|
||||
run: copy_to_document,
|
||||
journal: true,
|
||||
|
||||
@@ -28,6 +28,53 @@ fn copied(r: &Value) -> LayerId {
|
||||
LayerId(r["layers"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_only_copy_preserves_source_and_soft_coverage_across_documents() {
|
||||
for depth in [8, 16, 32] {
|
||||
let (mut s, paint) = two_docs(40, 30, depth);
|
||||
s.execute("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": paint.0, "opacity": 0.4})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).selection.as_mut().unwrap().write_pixel(7, 8, &[0.5]);
|
||||
let before = doc(&s, 0).clone();
|
||||
let r = s.execute("layer.copyToDocument", json!({"source": 0, "document": 1, "layers": [paint.0], "selectionOnly": true, "offset": [10, 5]})).unwrap();
|
||||
let id = copied(&r);
|
||||
assert_eq!(bounds(&s, 1, id), Rect::new(17, 13, 20, 17));
|
||||
assert_eq!(*doc(&s, 0), before);
|
||||
let pixel = doc(&s, 1).layer(id).unwrap().surface().unwrap().rgba(17, 13);
|
||||
assert!((doc(&s, 1).layer(id).unwrap().opacity - 0.4).abs() < 1e-6);
|
||||
assert_eq!(&pixel[..3], &[1.0, 0.0, 0.0]);
|
||||
assert!((pixel[3] - 0.5).abs() <= 1.0 / 255.0, "soft selection coverage survives depth conversion");
|
||||
assert!(s.undo());
|
||||
assert_eq!(doc(&s, 1).layers.len(), 1);
|
||||
assert_eq!(*doc(&s, 0), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_only_copy_rejects_a_floating_layer_mask() {
|
||||
let (mut s, paint) = two_docs(40, 30, 8);
|
||||
s.edit("mask", |doc, _| {
|
||||
doc.layer_mut(paint).unwrap().mask = Some(photocraft_doc::LayerMask::reveal_all());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap();
|
||||
s.execute("select.float", json!({"dx": 2, "dy": 0, "copy": true, "target": "mask"})).unwrap();
|
||||
let source = doc(&s, 0).clone();
|
||||
assert!(s.execute("layer.copyToDocument", json!({"source": 0, "document": 1, "layers": [paint.0], "selectionOnly": true})).is_err());
|
||||
assert_eq!(*doc(&s, 0), source);
|
||||
assert_eq!(doc(&s, 1).layers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_only_copy_rejects_missing_selection_and_wrong_parameter_type() {
|
||||
let (mut s, _) = two_docs(40, 30, 8);
|
||||
for option in [json!(true), json!("yes")] {
|
||||
assert!(s.execute("layer.copyToDocument", json!({"document": 1, "selectionOnly": option})).is_err());
|
||||
assert_eq!(doc(&s, 1).layers.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, i: usize, id: LayerId) -> Rect {
|
||||
crate::layer_multi_cmds::layer_bounds(doc(s, i).layer(id).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ pub struct Drag {
|
||||
/// The pointer, before snapping, has been half a pixel or more from the press: the gesture is
|
||||
/// not a click even if it comes back there (`tool_input::selection_release`, #11392).
|
||||
pub left_press: bool,
|
||||
/// Restore a pre-existing floating piece if this Move drag becomes a copy
|
||||
/// into another document; a fresh float belongs only to this gesture.
|
||||
pub transfer_floating_before: Option<photocraft_engine::float_cmds::Floating>,
|
||||
/// 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]>,
|
||||
@@ -64,6 +67,7 @@ impl Drag {
|
||||
reposition: false,
|
||||
sel_move: None,
|
||||
left_press: false,
|
||||
transfer_floating_before: None,
|
||||
sel_axis: None,
|
||||
lasso: None,
|
||||
vector_owner: None,
|
||||
|
||||
@@ -9,6 +9,7 @@ pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
crate::distort_ui::drop_foreign(app);
|
||||
crate::layer_transfer::prepare(app, ui.ctx());
|
||||
documents(app, ui);
|
||||
crate::layer_transfer::finish(app, ui.ctx());
|
||||
// Once a frame, not per document view: the right-click or options-bar Brush Preset picker.
|
||||
|
||||
@@ -306,9 +306,11 @@ fn handle_move_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tool: Tool, mods:
|
||||
// With a selection: cut the selected pixels (⌥ copies them) and drag them as a floating
|
||||
// piece, from anywhere, as a marquee ⌘-drag does.
|
||||
if crate::move_ui::moves_selected_pixels_with(app, tool) {
|
||||
let before = app.session.active().and_then(photocraft_engine::float_cmds::floating).cloned();
|
||||
if crate::move_ui::float_selected_for_drag(app, mods.alt) {
|
||||
let mut d = Drag::new(tool, [x, y], vec![[x, y, pressure as f64]], mods, false);
|
||||
d.sel_move = Some(true);
|
||||
d.transfer_floating_before = before;
|
||||
app.drag = Some(d);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -36,7 +36,7 @@ impl ViewXform {
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn pixel_aligned_for(self, app: &PhotocraftApp, ppp: f32) -> Self {
|
||||
pub(crate) fn pixel_aligned_for(self, app: &PhotocraftApp, ppp: f32) -> Self {
|
||||
// Default Crop counter-moves the frame and camera with fractional pointer deltas.
|
||||
// Preserve that continuous mapping so its fixed frame does not wobble during a drag.
|
||||
if crate::crop_mode::moves_image(app) { self } else { self.pixel_aligned(ppp) }
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
//! (Photoshop's ⇧-drag). With Window › Arrange tiling several documents, releasing over another
|
||||
//! document's tile drops there.
|
||||
//!
|
||||
//! A Move-tool drag starting inside a selection or moving a floating piece stays a move: Photoshop
|
||||
//! would copy just the selected pixels, which is not supported yet.
|
||||
//! A Move drag with a pixel selection copies only those pixels into a new layer.
|
||||
|
||||
use photocraft_doc::{DocId, LayerId};
|
||||
use serde_json::json;
|
||||
@@ -27,6 +26,7 @@ struct Transfer {
|
||||
grab: Option<[f64; 2]>,
|
||||
/// What the ghost by the pointer says.
|
||||
label: String,
|
||||
selection_only: bool,
|
||||
}
|
||||
|
||||
fn key() -> egui::Id {
|
||||
@@ -49,7 +49,10 @@ fn begin(app: &PhotocraftApp, ctx: &egui::Context, id: LayerId, grab: Option<[f6
|
||||
[one] => st.doc.layer(*one).map_or_else(String::new, |l| l.name.clone()),
|
||||
many => crate::i18n::trn(crate::i18n::current(), many.len() as u64, "{n} layer", "{n} layers"),
|
||||
};
|
||||
let t = Transfer { source: st.doc.id, layers: layers.iter().map(|l| l.0).collect(), grab, label };
|
||||
// Several selected layers move whole; the engine's selected-pixel copy intentionally accepts
|
||||
// exactly one raster layer.
|
||||
let selection_only = grab.is_some() && layers.len() == 1 && st.doc.selection.is_some();
|
||||
let t = Transfer { source: st.doc.id, layers: layers.iter().map(|l| l.0).collect(), grab, label, selection_only };
|
||||
ctx.data_mut(|d| d.insert_temp(key(), t));
|
||||
true
|
||||
}
|
||||
@@ -61,9 +64,9 @@ pub fn begin_from_panel(app: &PhotocraftApp, ctx: &egui::Context, layer: LayerId
|
||||
|
||||
/// A Move-tool drag of whole layers (not a selection or a floating piece) under way.
|
||||
fn move_drag(app: &PhotocraftApp) -> Option<[f64; 2]> {
|
||||
let d = app.drag.as_ref().filter(|d| d.tool == Tool::Move && d.sel_move.is_none())?;
|
||||
let d = app.drag.as_ref().filter(|d| d.tool == Tool::Move && d.sel_move != Some(false))?;
|
||||
let st = app.session.active()?;
|
||||
let plain = app.ui.transform.is_none() && app.guide_drag.is_none() && st.doc.selection.is_none() && st.floating.is_none();
|
||||
let plain = app.ui.transform.is_none() && app.guide_drag.is_none() && ((st.doc.selection.is_none() && st.floating.is_none()) || d.sel_move == Some(true));
|
||||
plain.then_some(d.start)
|
||||
}
|
||||
|
||||
@@ -79,22 +82,51 @@ pub fn pointer_if_armed(app: &PhotocraftApp, ctx: &egui::Context) -> Option<egui
|
||||
/// The pointer is over document tab `index` while layers are dragged: a Move drag becomes a
|
||||
/// transfer (the source layers stay where they were), the tab's document is shown, and a release
|
||||
/// right here drops the layers into it.
|
||||
pub fn over_tab(app: &mut PhotocraftApp, ctx: &egui::Context, index: usize) {
|
||||
fn arm_move_transfer(app: &mut PhotocraftApp, ctx: &egui::Context, index: usize) -> bool {
|
||||
if get(ctx).is_none() {
|
||||
let Some(grab) = move_drag(app) else { return };
|
||||
let Some(active) = app.session.active().and_then(|st| st.active_layer) else { return };
|
||||
let Some(grab) = move_drag(app) else { return false };
|
||||
let Some(active) = app.session.active().and_then(|st| st.active_layer) else { return false };
|
||||
if app.session.active_index() == Some(index) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let Some(previous) = app.drag.as_ref().map(|d| d.transfer_floating_before.clone()) else { return false };
|
||||
// An Option-drag of an existing float first drops it and floats a copy. Take that
|
||||
// compound start back before restoring the captured float, so document, history and
|
||||
// floating revision remain one consistent source state.
|
||||
if previous.as_ref().is_some_and(|before| app.session.active().is_some_and(|st| st.revision != before.revision)) {
|
||||
// The drag's own float is gone or stale: another edit (an opacity key, say) came
|
||||
// after the drag started, and a rollback would take that edit back instead. The
|
||||
// drag stays a move in its own document.
|
||||
if app.session.active().and_then(photocraft_engine::float_cmds::floating).is_none() {
|
||||
return false;
|
||||
}
|
||||
// Nothing is taken from the drag before this can fail (a background job holds the
|
||||
// history): it goes on as it was, with its ⇧ lock and offset (`move_mods`).
|
||||
if !app.session.rollback() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// The Move drag ends without moving anything; an ⌥-drag's copy is taken back.
|
||||
app.drag = None;
|
||||
app.move_preview = None;
|
||||
crate::move_mods::abandon(app);
|
||||
// The rollback raised the revision; the restored document is the one the float was
|
||||
// over, so it floats over the new revision (else `floating()` drops it as stale).
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
st.floating = previous.map(|f| photocraft_engine::float_cmds::Floating { revision: st.revision, ..f });
|
||||
}
|
||||
let active = app.session.active().and_then(|st| st.active_layer).unwrap_or(active);
|
||||
if !begin(app, ctx, active, Some(grab)) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn over_tab(app: &mut PhotocraftApp, ctx: &egui::Context, index: usize) {
|
||||
if !arm_move_transfer(app, ctx, index) {
|
||||
return;
|
||||
}
|
||||
if app.session.active_index() != Some(index) {
|
||||
app.session.set_active(index);
|
||||
app.jobs.focus = None;
|
||||
@@ -131,14 +163,47 @@ pub fn finish(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
clear(ctx);
|
||||
}
|
||||
|
||||
/// The document under screen point `p` that isn't the source, and the document point there
|
||||
/// (`None` in a tile: tiles keep their own views).
|
||||
/// Run before canvases process release: a tiled destination must take over the
|
||||
/// drag before the source can commit it as an ordinary move.
|
||||
pub(crate) fn prepare(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if get(ctx).is_some() {
|
||||
return;
|
||||
}
|
||||
// Do not consume a source drag merely because it passes over another tile. On release this
|
||||
// runs before canvas input, so a destination can take ownership without the source committing;
|
||||
// returning to the source still releases as the original move.
|
||||
if !ctx.input(|i| i.pointer.any_released()) {
|
||||
return;
|
||||
}
|
||||
let Some(pos) = pointer_if_armed(app, ctx) else { return };
|
||||
let Some((dest, _)) = target_at(app, pos) else { return };
|
||||
if app.session.active_index() != Some(dest) && arm_move_transfer(app, ctx, dest) {
|
||||
app.session.set_active(dest);
|
||||
app.jobs.focus = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The document under screen point `p` and the document point there. Arranged tiles use their
|
||||
/// own view transform, so selected pixels and plain layers both land at the visible drop point.
|
||||
fn target_at(app: &PhotocraftApp, p: egui::Pos2) -> Option<(usize, Option<[f64; 2]>)> {
|
||||
if !app.last_canvas_rect.contains(p) {
|
||||
return None;
|
||||
}
|
||||
match crate::canvas::arranged_cells(app, app.last_canvas_rect) {
|
||||
Some(cells) => cells.into_iter().find(|(_, r)| r.contains(p)).map(|(d, _)| (d, None)),
|
||||
Some(cells) => cells.into_iter().find(|(_, r)| r.contains(p)).and_then(|(d, r)| {
|
||||
let view = app.ui.views.get(d)?;
|
||||
let ppp = app.canvas_ppp();
|
||||
let xf = crate::canvas::ViewXform {
|
||||
rect: crate::rulers::content_rect(app, r.shrink(1.0)),
|
||||
zoom: view.zoom / ppp,
|
||||
center: view.center,
|
||||
flip: app.ui.view.flip_horizontal,
|
||||
rotation: view.rotation,
|
||||
aspect: app.ui.view.display_aspect(),
|
||||
}
|
||||
.pixel_aligned_for(app, ppp);
|
||||
Some((d, Some(xf.to_doc(p))))
|
||||
}),
|
||||
None => Some((app.session.active_index()?, crate::canvas::ViewXform::active(app).map(|xf| xf.to_doc(p)))),
|
||||
}
|
||||
}
|
||||
@@ -166,6 +231,7 @@ fn drop(app: &mut PhotocraftApp, ctx: &egui::Context, dest: usize, at: Option<[f
|
||||
let Some(to) = docs.get(dest).filter(|_| dest != src) else { return };
|
||||
let same_size = to.doc.size == docs[src].doc.size;
|
||||
let mut p = json!({"document": dest, "source": src, "layers": t.layers});
|
||||
p["selectionOnly"] = json!(t.selection_only);
|
||||
match at.filter(|_| !ctx.input(|i| i.modifiers.shift)) {
|
||||
Some(at) => match t.grab {
|
||||
Some(g) => p["offset"] = json!([(at[0] - g[0]).round(), (at[1] - g[1]).round()]),
|
||||
|
||||
@@ -38,10 +38,16 @@ fn tab(h: &Harness<'_, PhotocraftApp>, name: &str) -> Pos2 {
|
||||
/// Press at `path[0]`, move through the rest (a few frames at each) and release at the last
|
||||
/// point with `release` held.
|
||||
fn drag_along(h: &mut Harness<'_, PhotocraftApp>, path: &[Pos2], release: Modifiers) {
|
||||
drag_along_pressed(h, path, Modifiers::NONE, release);
|
||||
}
|
||||
|
||||
/// [`drag_along`], with `press` held from the press on (⌥ for a Move-tool copy).
|
||||
fn drag_along_pressed(h: &mut Harness<'_, PhotocraftApp>, path: &[Pos2], press: Modifiers, release: Modifiers) {
|
||||
let first = path[0];
|
||||
h.event(egui::Event::ModifiersChanged(press));
|
||||
h.event(egui::Event::PointerMoved(first));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: first, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.event(egui::Event::PointerButton { pos: first, button: PointerButton::Primary, pressed: true, modifiers: press });
|
||||
h.run_steps(1);
|
||||
for w in path.windows(2) {
|
||||
for k in 1..=4 {
|
||||
@@ -72,6 +78,235 @@ fn source_square(h: &Harness<'_, PhotocraftApp>, paint: LayerId) -> photocraft_g
|
||||
photocraft_engine::layer_multi_cmds::layer_bounds(doc.layer(paint).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_selection_across_tab_copies_only_selected_pixels_and_keeps_source_history() {
|
||||
let (mut h, paint) = harness();
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().run("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap();
|
||||
h.run_steps(2);
|
||||
let before = h.state().session.documents()[0].doc.clone();
|
||||
let history = h.state().session.documents()[0].history.entries().len();
|
||||
let grab = ViewXform::active(h.state()).unwrap().to_screen(8.0, 10.0);
|
||||
let over_tab = tab(&h, "dest-doc");
|
||||
let drop = h.state().last_canvas_rect.center();
|
||||
drag_along(&mut h, &[grab, over_tab, drop], Modifiers::NONE);
|
||||
let (_, r) = copy(&h);
|
||||
assert_eq!(r.size(), vec2(3.0, 4.0));
|
||||
let at = ViewXform::active(h.state()).unwrap().to_doc(drop);
|
||||
assert!((r.min.x - (7.0 + at[0] - 8.0) as f32).abs() <= 1.0);
|
||||
assert_eq!(*h.state().session.documents()[0].doc, *before);
|
||||
assert_eq!(h.state().session.documents()[0].history.entries().len(), history);
|
||||
assert_eq!(source_square(&h, paint), photocraft_geom::Rect::new(4, 6, 14, 16));
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.state().session.documents()[1].doc.layers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_dropped_outside_documents_copies_nothing() {
|
||||
let (mut h, _) = harness();
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().run("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap();
|
||||
h.run_steps(2);
|
||||
let before = h.state().session.documents()[0].doc.clone();
|
||||
let grab = ViewXform::active(h.state()).unwrap().to_screen(8.0, 10.0);
|
||||
let dest = tab(&h, "dest-doc");
|
||||
drag_along(&mut h, &[grab, dest, Pos2::new(-10.0, -10.0)], Modifiers::NONE);
|
||||
assert_eq!(*h.state().session.documents()[0].doc, *before);
|
||||
assert_eq!(h.state().session.documents()[1].doc.layers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_can_be_dragged_directly_between_tiled_canvases() {
|
||||
let (mut h, _) = harness();
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().ui.view.arrange = "twoUpVertical".into();
|
||||
h.state_mut().run("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap();
|
||||
h.run_steps(3);
|
||||
let before = h.state().session.documents()[0].doc.clone();
|
||||
let cells = crate::canvas::arranged_cells(h.state(), h.state().last_canvas_rect).unwrap();
|
||||
let view = &h.state().ui.views[0];
|
||||
let xf = ViewXform {
|
||||
rect: crate::rulers::content_rect(h.state(), cells[0].1.shrink(1.0)),
|
||||
zoom: view.zoom / h.state().ppp,
|
||||
center: view.center,
|
||||
flip: false,
|
||||
rotation: view.rotation,
|
||||
aspect: 1.0,
|
||||
};
|
||||
let grab = xf.to_screen(8.0, 10.0);
|
||||
let drop = cells[1].1.center();
|
||||
drag_along(&mut h, &[grab, drop], Modifiers::NONE);
|
||||
assert_eq!(copy(&h).1.size(), vec2(3.0, 4.0));
|
||||
assert_eq!(*h.state().session.documents()[0].doc, *before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_layer_can_be_dragged_directly_between_tiled_canvases() {
|
||||
let (mut h, paint) = harness();
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().ui.view.arrange = "twoUpVertical".into();
|
||||
h.run_steps(3);
|
||||
let before = source_square(&h, paint);
|
||||
let cells = crate::canvas::arranged_cells(h.state(), h.state().last_canvas_rect).unwrap();
|
||||
let view = &h.state().ui.views[0];
|
||||
let source_xf = ViewXform {
|
||||
rect: crate::rulers::content_rect(h.state(), cells[0].1.shrink(1.0)),
|
||||
zoom: view.zoom / h.state().ppp,
|
||||
center: view.center,
|
||||
flip: false,
|
||||
rotation: view.rotation,
|
||||
aspect: 1.0,
|
||||
};
|
||||
let grab = source_xf.to_screen(9.0, 11.0);
|
||||
let drop = cells[1].1.center();
|
||||
drag_along(&mut h, &[grab, drop], Modifiers::NONE);
|
||||
let (name, copied) = copy(&h);
|
||||
assert_eq!(name, "paint");
|
||||
assert_eq!(copied.size(), vec2(10.0, 10.0));
|
||||
assert_eq!(source_square(&h, paint), before, "the tiled transfer preserves the source layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tiled_selection_drag_that_returns_to_its_source_still_moves_there() {
|
||||
let (mut h, _) = harness();
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().ui.view.arrange = "twoUpVertical".into();
|
||||
h.state_mut().run("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap();
|
||||
h.run_steps(3);
|
||||
let before = h.state().session.documents()[0].doc.clone();
|
||||
let history = h.state().session.documents()[0].history.past_len();
|
||||
let cells = crate::canvas::arranged_cells(h.state(), h.state().last_canvas_rect).unwrap();
|
||||
let view = &h.state().ui.views[0];
|
||||
let ppp = h.state().canvas_ppp();
|
||||
let source_xf = ViewXform {
|
||||
rect: crate::rulers::content_rect(h.state(), cells[0].1.shrink(1.0)),
|
||||
zoom: view.zoom / ppp,
|
||||
center: view.center,
|
||||
flip: false,
|
||||
rotation: view.rotation,
|
||||
aspect: 1.0,
|
||||
}
|
||||
.pixel_aligned_for(h.state(), ppp);
|
||||
let grab = source_xf.to_screen(8.0, 10.0);
|
||||
let moved = source_xf.to_screen(13.0, 10.0);
|
||||
assert!(cells[0].1.contains(moved), "release {moved:?} is outside source cell {:?}", cells[0].1);
|
||||
drag_along(&mut h, &[grab, cells[1].1.center(), moved], Modifiers::NONE);
|
||||
let state = h.state();
|
||||
assert_eq!(*state.session.documents()[0].doc, *before, "a selection move remains floating until it is dropped");
|
||||
let floating = state.session.documents()[0].floating.as_ref().expect("returning to the source preserves and releases the original move drag");
|
||||
assert!(floating.moved);
|
||||
assert_ne!(floating.offset, (0, 0));
|
||||
assert_eq!(state.session.documents()[0].history.past_len(), history);
|
||||
assert_eq!(h.state().session.documents()[1].doc.layers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_drag_of_a_moved_float_onto_a_tab_keeps_the_source_float() {
|
||||
let (mut h, _) = harness();
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().run("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap();
|
||||
h.state_mut().run("select.float", json!({"dx": 5, "dy": 0})).unwrap();
|
||||
h.run_steps(2);
|
||||
let source = &h.state().session.documents()[0];
|
||||
let (before, history) = (source.doc.clone(), source.history.past_len());
|
||||
assert!(photocraft_engine::float_cmds::floating(source).is_some_and(|f| f.offset == (5, 0)));
|
||||
let grab = ViewXform::active(h.state()).unwrap().to_screen(13.0, 10.0);
|
||||
let over_tab = tab(&h, "dest-doc");
|
||||
let drop = h.state().last_canvas_rect.center();
|
||||
drag_along_pressed(&mut h, &[grab, over_tab, drop], Modifiers::ALT, Modifiers::NONE);
|
||||
assert_eq!(copy(&h).1.size(), vec2(3.0, 4.0));
|
||||
let source = &h.state().session.documents()[0];
|
||||
assert_eq!(*source.doc, *before);
|
||||
assert_eq!(source.history.past_len(), history);
|
||||
// The rollback of the ⌥-drag's drop-and-copy raised the revision: the restored float must
|
||||
// still count as floating over it, or the earlier move is lost.
|
||||
let float = photocraft_engine::float_cmds::floating(source).expect("the moved float survives the transfer");
|
||||
assert_eq!(float.offset, (5, 0));
|
||||
}
|
||||
|
||||
/// A moved float in "source-doc", the Move tool, and an ⌥-press on it, moved a little so the
|
||||
/// drag has dropped the float and floats its copy. Returns the tab of "dest-doc".
|
||||
fn option_drag_of_a_moved_float(h: &mut Harness<'_, PhotocraftApp>) -> Pos2 {
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().run("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap();
|
||||
h.state_mut().run("select.float", json!({"dx": 5, "dy": 0})).unwrap();
|
||||
h.run_steps(2);
|
||||
let grab = ViewXform::active(h.state()).unwrap().to_screen(13.0, 10.0);
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::ALT));
|
||||
h.event(egui::Event::PointerMoved(grab));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: grab, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::ALT });
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.event(egui::Event::PointerMoved(grab + vec2(0.0, 10.0 * k as f32)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
tab(h, "dest-doc")
|
||||
}
|
||||
|
||||
fn release_at(h: &mut Harness<'_, PhotocraftApp>, p: Pos2) {
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_during_an_option_drag_of_a_float_is_not_taken_back_by_a_tab_hover() {
|
||||
let (mut h, paint) = harness();
|
||||
let over_tab = option_drag_of_a_moved_float(&mut h);
|
||||
// An edit lands while the button is still held (as an opacity key would).
|
||||
h.state_mut().run("layer.setProps", json!({"layer": paint.0, "opacity": 0.3})).unwrap();
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.event(egui::Event::PointerMoved(over_tab + vec2(0.0, k as f32)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
let opacity = |h: &Harness<'_, PhotocraftApp>| h.state().session.documents()[0].doc.layer(paint).unwrap().opacity;
|
||||
assert!((opacity(&h) - 0.3).abs() < 1e-6, "the hover took the opacity edit back");
|
||||
release_at(&mut h, over_tab);
|
||||
assert!((opacity(&h) - 0.3).abs() < 1e-6, "the release took the opacity edit back");
|
||||
assert_eq!(h.state().session.documents()[1].doc.layers.len(), 1, "nothing is copied over a later edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tab_hover_that_cannot_take_the_option_drag_back_keeps_the_move_going() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
let (mut h, _) = harness();
|
||||
let over_tab = option_drag_of_a_moved_float(&mut h);
|
||||
// A background job locks the source: the rollback can't run.
|
||||
let gate = Arc::new(AtomicBool::new(false));
|
||||
let open = gate.clone();
|
||||
h.state_mut()
|
||||
.session
|
||||
.start_job(
|
||||
"test.slow",
|
||||
json!({}),
|
||||
"Slow",
|
||||
true,
|
||||
move |ctx| {
|
||||
while !open.load(Ordering::Relaxed) {
|
||||
ctx.check()?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
|_, ()| Ok(json!({})),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(h.state().session.active_job().is_some());
|
||||
for k in 1..=4 {
|
||||
h.event(egui::Event::PointerMoved(over_tab + vec2(0.0, k as f32)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
// Still the same ⌥-drag of the copy, its modifier state intact.
|
||||
assert_eq!(h.state().session.active().unwrap().doc.name, "source-doc");
|
||||
assert!(h.state().drag.is_some());
|
||||
assert!(crate::move_mods::copying(h.state(), false), "the drag's move state was cleared");
|
||||
gate.store(true, Ordering::Relaxed);
|
||||
release_at(&mut h, over_tab);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layers_panel_drag_onto_a_tab_drops_the_layer_where_it_is_released() {
|
||||
let (mut h, _) = harness();
|
||||
|
||||
Reference in new issue
Block a user