diff --git a/crates/engine/src/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index d1b9690312..c9c1b8b9c1 100644 --- a/crates/engine/src/cmd/transform_cmds.rs +++ b/crates/engine/src/cmd/transform_cmds.rs @@ -57,7 +57,7 @@ fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect { } /// A group moves all its descendants, so its frame must not clip raster children to the selection. -pub(crate) fn whole_layer_bounds(doc: &Document, layer: &Layer) -> Rect { +pub fn whole_layer_bounds(doc: &Document, layer: &Layer) -> Rect { let content = match &layer.content { LayerContent::Group(g) => g.children.iter().map(|c| whole_layer_bounds(doc, c)).fold(Rect::EMPTY, |a, b| a.union(&b)), // A soft shape (Density below 100 % or a Feather) renders past its path, over the whole @@ -734,6 +734,7 @@ fn with_panel_target(s: &Session, p: &Value) -> Value { let mut p = if p.is_object() { p.clone() } else { json!({}) }; if p.get("target").is_none() && p.get("layer").is_none() + && p.get("layers").is_none() && let (Some(m), Some(Value::Object(t))) = (p.as_object_mut(), crate::channel_cmds::targeted_channel(s)) { m.extend(t); @@ -741,15 +742,60 @@ fn with_panel_target(s: &Session, p: &Value) -> Value { p } +/// Several layers explicitly targeted by a transform. This is used by the Move tool's shared +/// transform controls: the same homography is applied to every top-level layer in one history +/// step, relative to the shared source frame. A group already carries its selected descendants, +/// so those descendants are removed from the effective target set. +fn explicit_layers(doc: &Document, p: &Value) -> Result>> { + let Some(raw) = p.get("layers") else { return Ok(None) }; + if p.get("layer").is_some_and(|v| !v.is_null()) { + return Err(bad("use either `layer` or `layers`, not both")); + } + let a = raw.as_array().ok_or_else(|| bad("`layers` must be an array of layer ids"))?; + if a.is_empty() { + return Err(bad("`layers` must contain at least one layer id")); + } + let mut ids = Vec::with_capacity(a.len()); + for v in a { + let id = v.as_u64().map(LayerId).ok_or_else(|| bad("every `layers` entry must be a non-negative integer layer id"))?; + if doc.layer(id).is_none() { + return Err(EngineError::NoLayer(id)); + } + if !ids.contains(&id) { + ids.push(id); + } + } + let ids = crate::layer_multi_cmds::top_level(doc, &ids); + if ids.is_empty() { + return Err(bad("`layers` contains no transformable layers")); + } + Ok(Some(ids)) +} + +fn common_bounds(doc: &Document, ids: &[LayerId]) -> Result { + let mut b = Rect::EMPTY; + for &id in ids { + let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + b = b.union(&whole_layer_bounds(doc, layer)); + } + Ok(b) +} + fn transform(s: &mut Session, p: &Value) -> Result { let p = &with_panel_target(s, p); let st = s.active().ok_or(EngineError::NoDocument)?; - let id = crate::commands::explicit_layer(s, p)?.or(st.active_layer); - let lone = lone_target(&st.doc, id, p)?; - let id = match (id, lone.is_some() && crate::channel_cmds::is_channel_target(p)) { - (_, true) => None, - (Some(id), false) => Some(id), - (None, false) => return Err(EngineError::Other("no active layer".into())), + let layers = explicit_layers(&st.doc, p)?; + if layers.is_some() && p.get("target").is_some() { + return Err(bad("`target` cannot be combined with `layers`")); + } + let explicit = crate::commands::explicit_layer(s, p)?; + let id = if layers.is_some() { None } else { explicit.or(st.active_layer) }; + let lone = if layers.is_some() { None } else { lone_target(&st.doc, id, p)? }; + let id = match (id, lone.is_some() && crate::channel_cmds::is_channel_target(p), layers.is_some()) { + (_, _, true) => None, + (_, true, false) => None, + (Some(id), false, false) => Some(id), + (None, false, false) => return Err(EngineError::Other("no active layer".into())), }; let rect = match p.get("rect").and_then(Value::as_array) { Some(r) if r.len() == 4 => { @@ -757,10 +803,11 @@ fn transform(s: &mut Session, p: &Value) -> Result { [v[0], v[1], v[2], v[3]] } _ => { - let b = match (lone, id) { - (Some(surf), _) => target_bounds(&st.doc, surf), - (None, Some(id)) => transform_bounds(&st.doc, st.doc.layer(id).ok_or(EngineError::NoLayer(id))?), - (None, None) => Rect::EMPTY, + let b = match (&layers, lone, id) { + (Some(ids), _, _) => common_bounds(&st.doc, ids)?, + (None, Some(surf), _) => target_bounds(&st.doc, surf), + (None, None, Some(id)) => transform_bounds(&st.doc, st.doc.layer(id).ok_or(EngineError::NoLayer(id))?), + (None, None, None) => Rect::EMPTY, }; if b.is_empty() { return Err(EngineError::Other("nothing to transform".into())); @@ -790,17 +837,77 @@ fn transform(s: &mut Session, p: &Value) -> Result { (m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] }); let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); let lone = lone.is_some(); + let layers_for_job = layers.clone(); let p = p.clone(); // A background job when started with `Session::start` (#1015): the warp of a large layer runs // on a worker with progress, and can be cancelled. crate::jobs::edit_job( s, "Free Transform", - move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &p, lone, &h, affine, interp, ctl)), + move |doc, _, ctx| { + ctx.stage(0.0, 1.0, "Transforming", |ctl| { + if let Some(ids) = &layers_for_job { + let several = crate::layer_multi_cmds::top_level(doc, ids).len() > 1; + transform_layers_doc(doc, ids, &h, affine, interp, ctl)?; + // Photoshop drops the selection after transforming several layers (whole + // layers move, so the outline no longer frames anything); in the same edit, + // so one undo brings both back. A list that is one layer (or a group and + // its own children) keeps it. + if several && !h.is_identity() { + doc.selection = None; + } + Ok(()) + } else { + transform_doc(doc, id, &p, lone, &h, affine, interp, ctl) + } + }) + }, move |()| json!({"rect": rect}), ) } +/// Apply one homography to several top-level layers in one edit. Multi-layer transforms move the +/// layers as whole objects: a pixel selection does not crop each selected layer independently. +/// The selection is left alone here (the live preview keeps showing it); the committed +/// `edit.transform` drops it, as Photoshop does. +fn transform_layers_doc(doc: &mut Document, ids: &[LayerId], h: &Homography, affine: Option, interp: Interp, ctl: &Interrupt) -> Result<()> { + let ids = crate::layer_multi_cmds::top_level(doc, ids); + if ids.is_empty() { + return Err(EngineError::Other("no layers to transform".into())); + } + for &id in &ids { + let group = group_locks(doc, id); + crate::extra_cmds::background_to_layer(doc, id); + let layer = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + transform_layer_with(None, group, layer, h, affine, interp, ctl)?; + } + if !h.is_identity() { + let snapshot = doc.clone(); + for &id in &ids { + if let Some(layer) = doc.layer_mut(id) { + refresh_text(&snapshot, layer); + } + } + } + Ok(()) +} + +/// A synchronous document clone for the Move tool's live preview of several selected layers. +/// The source document is never mutated. +pub fn preview_layers(doc: &Document, ids: &[LayerId], rect: [f64; 4], quad: [[f64; 2]; 4], interpolation: &str) -> Result { + let h = Homography::rect_to_quad(rect, quad).ok_or_else(|| bad("degenerate quad"))?; + if h.inverse().is_none() { + return Err(bad("the transform collapses the layers")); + } + let m = h.0; + let affine = + (m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] }); + let interp = Interp::parse(interpolation); + let mut out = doc.clone(); + transform_layers_doc(&mut out, ids, &h, affine, interp, &Interrupt::NONE)?; + Ok(out) +} + /// The edit of [`transform`]: the layer (or a lone mask or channel) and the selection outline. #[allow(clippy::too_many_arguments)] fn transform_doc( @@ -865,7 +972,7 @@ pub fn specs() -> Vec { label: "Free Transform", menu: &[], shortcut: None, - params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, + params: r##"{"layer":id? OR "layers":[id,…]? (shared multi-layer transform; top-level layers only; two or more drop the selection),"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection, or the union of multi-layer content),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (single-layer only; an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, enabled: has_layer, journal: true, run: transform, diff --git a/crates/engine/src/cmd/transform_cmds/tests.rs b/crates/engine/src/cmd/transform_cmds/tests.rs index f8ff204257..29b06a89ab 100644 --- a/crates/engine/src/cmd/transform_cmds/tests.rs +++ b/crates/engine/src/cmd/transform_cmds/tests.rs @@ -23,6 +23,103 @@ fn active_bounds(s: &Session) -> Rect { st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds() } +#[test] +fn shared_transform_moves_several_layers_in_one_command() { + let mut s = session(); + let first = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let second = s.active().unwrap().active_layer.unwrap(); + s.edit("paint second", |doc, _| { + doc.layer_mut(second).unwrap().surface_mut().unwrap().fill_rect(Rect::new(50, 40, 70, 60), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + + s.execute( + "edit.transform", + json!({ + "layers": [first.0, second.0], + "rect": [10.0, 10.0, 70.0, 60.0], + "quad": [[25.0, 30.0], [85.0, 30.0], [85.0, 80.0], [25.0, 80.0]], + "interpolation": "nearest" + }), + ) + .unwrap(); + + let st = s.active().unwrap(); + let a = st.doc.layer(first).unwrap().surface().unwrap().content_bounds(); + let b = st.doc.layer(second).unwrap().surface().unwrap().content_bounds(); + assert_eq!(a, Rect::new(25, 30, 45, 40)); + assert_eq!(b, Rect::new(65, 60, 85, 80)); +} + +/// Photoshop, with two layers selected and a marquee active: Free Transform moves both whole +/// layers and drops the selection. One undo restores the layers and the selection. +#[test] +fn shared_transform_moves_whole_layers_and_drops_the_selection() { + let mut s = session(); + let first = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let second = s.active().unwrap().active_layer.unwrap(); + s.edit("paint second", |doc, _| { + doc.layer_mut(second).unwrap().surface_mut().unwrap().fill_rect(Rect::new(50, 40, 70, 60), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 15, "height": 100})).unwrap(); + let before_a = s.active().unwrap().doc.layer(first).unwrap().surface().unwrap().content_bounds(); + let sel_before = s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + + s.execute("edit.transform", json!({"layers": [first.0, second.0], "dx": 20, "dy": 0, "interpolation": "nearest"})).unwrap(); + let st = s.active().unwrap(); + let a = st.doc.layer(first).unwrap().surface().unwrap().content_bounds(); + let b = st.doc.layer(second).unwrap().surface().unwrap().content_bounds(); + assert_eq!(a, Rect::new(before_a.x0 + 20, before_a.y0, before_a.x1 + 20, before_a.y1), "the whole layer moves"); + assert_eq!(b, Rect::new(70, 40, 90, 60)); + assert!(st.doc.selection.is_none(), "the selection is dropped"); + + s.execute("edit.undo", json!({})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(first).unwrap().surface().unwrap().content_bounds(), before_a); + assert_eq!(st.doc.selection.as_ref().map(|m| m.content_bounds()), Some(sel_before), "one undo brings the selection back"); +} + +/// A `layers` list that is only one layer is no multi-layer transform: the selection stays. +#[test] +fn a_one_layer_list_keeps_the_selection() { + let mut s = session(); + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 15, "height": 100})).unwrap(); + s.execute("edit.transform", json!({"layers": [id.0], "dx": 20, "dy": 0})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_some()); +} + +/// A single layer keeps its behaviour: only the selected pixels move, and the selection with them. +#[test] +fn single_layer_transform_still_moves_the_selection() { + let mut s = session(); + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + s.execute("edit.transform", json!({"layer": id.0, "dx": 20, "dy": 0})).unwrap(); + let sel = s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + assert_eq!((sel.x0, sel.x1), (30, 40)); +} + +#[test] +fn shared_transform_rejects_invalid_layer_lists() { + let mut s = session(); + let active = s.active().unwrap().active_layer.unwrap(); + for params in [ + json!({"layers": "not-an-array", "matrix": [1, 0, 0, 1, 1, 1]}), + json!({"layers": [], "matrix": [1, 0, 0, 1, 1, 1]}), + json!({"layers": [u64::MAX], "matrix": [1, 0, 0, 1, 1, 1]}), + json!({"layer": active.0, "layers": [active.0], "matrix": [1, 0, 0, 1, 1, 1]}), + json!({"layers": [active.0], "target": "mask", "matrix": [1, 0, 0, 1, 1, 1]}), + ] { + assert!(s.execute("edit.transform", params).is_err()); + } +} + // ---- Memory budget (#3773, #7807). Tests run with a 20 MB budget (2²⁰ px of 32-bit CMYK with // alpha), so nothing here comes near the real one. ---- diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index d12c85d005..88842ec81e 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -144,6 +144,7 @@ impl PhotocraftApp { clip_external: false, clip_read_for_paste: false, transform_preview: None, + multi_transform: None, move_mods: Default::default(), nudge_bundle: Default::default(), style_preview: None, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3896b84cf6..c0e692e1d6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -114,11 +114,10 @@ use textures::{clamp_texture_side, preview_bounds, texture_factor}; use textures::{ensure_gpu, redraw_focus, visible_doc_rect}; pub use tool_input::{ToolEvent, selection_drag_kind, tool_event}; pub(crate) use tool_input::{command_guide_at, command_moves_layer, event_tool}; +#[cfg(test)] +use transform_controls::transform_controls_several; pub(crate) use transform_controls::{LAYER_EDGES, layer_edges_rect}; -use transform_controls::{ - begin_transform_controls_at, draw_transform_controls, transform_controls_cursor, transform_controls_rect, transform_controls_several, - transform_controls_turn, -}; +use transform_controls::{begin_transform_controls_at, draw_transform_controls, transform_controls_cursor, transform_controls_rect, transform_controls_turn}; #[cfg(test)] use transform_controls::{transform_controls_hit, transform_controls_layers}; pub use view_xform::{ViewXform, doc_point_at, fill_view, fit_view}; @@ -1360,10 +1359,8 @@ fn draw_canvas_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: &C // corner badge, as Photoshop's corner radius cursor. ui.ctx().set_cursor_icon(egui::CursorIcon::Default); crate::icons::cursor_badge(ctx, "scan", p); - } else if let Some((r, p, c)) = response - .hover_pos() - .filter(|_| !transform_controls_several(app)) - .and_then(|p| transform_controls_rect(app, xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) + } else if let Some((r, p, c)) = + response.hover_pos().and_then(|p| transform_controls_rect(app, xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) { // The Move tool draws handles without opening a Free Transform session. // Give them their resize/rotate cursors before the first pointer press (#2871). diff --git a/crates/ui-egui/src/canvas/display.rs b/crates/ui-egui/src/canvas/display.rs index 3b111232bd..4f4f1c4ba7 100644 --- a/crates/ui-egui/src/canvas/display.rs +++ b/crates/ui-egui/src/canvas/display.rs @@ -140,6 +140,12 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar if let Some(shown) = crate::gradient_ui::display_doc(app, idx) { return shown; } + // Move › Show Transform Controls on several selected layers: one shared live transform. + // It comes before the Move preview so a floating selection can remain part of that shared + // transform instead of short-circuiting to its untransformed floating preview. + if let Some(shown) = crate::transform_tool::multi_display_doc(app, idx) { + return shown; + } // Move tool drag: the moving layers at the pointer. if let Some(shown) = crate::move_ui::display_doc(app, idx) { return shown; diff --git a/crates/ui-egui/src/canvas/snap_ui.rs b/crates/ui-egui/src/canvas/snap_ui.rs index 05365dab54..6e2107fd8a 100644 --- a/crates/ui-egui/src/canvas/snap_ui.rs +++ b/crates/ui-egui/src/canvas/snap_ui.rs @@ -215,16 +215,18 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { let moves_layer = crate::canvas::command_moves_layer(app, tool, p, mods); let crop_tol = crate::crop_ui::tolerance(app); let gesture = if let Some(t) = &app.ui.transform { + // A shared box moves every layer of its session: none of them is a target. + let moving = crate::transform_tool::moving_layers(app).unwrap_or_else(|| vec![LayerId(t.layer)]); let corner = match crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app)) { - crate::transform_tool::Hit::Corner(index) => t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, vec![LayerId(t.layer)])), + crate::transform_tool::Hit::Corner(index) => t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, moving.clone())), _ => None, }; if t.mode == crate::state::TransformMode::Distort { corner } else if near_handle(&t.quad, p, crate::transform_tool::handle_tolerance(app)) { - corner.or_else(|| Some((Gesture::Point, vec![LayerId(t.layer)]))) + corner.or(Some((Gesture::Point, moving))) } else if in_quad(&t.quad, p) { - Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, vec![LayerId(t.layer)])) + Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, moving)) } else { None } diff --git a/crates/ui-egui/src/canvas/snap_ui/tests.rs b/crates/ui-egui/src/canvas/snap_ui/tests.rs index 56617d906e..2dec55ed4b 100644 --- a/crates/ui-egui/src/canvas/snap_ui/tests.rs +++ b/crates/ui-egui/src/canvas/snap_ui/tests.rs @@ -617,6 +617,35 @@ fn distort_corner_does_not_snap_to_layer_edges() { assert!(app.prefs_rt.snap_lines.is_empty()); } +/// A shared Free Transform box for several layers doesn't snap to those layers' original +/// edges: they move with it. Its left edge starts exactly on the mover's left edge, so a +/// 2 px drag snapped straight back when only the active layer was left out. +#[test] +fn a_shared_transform_box_does_not_snap_to_its_own_layers() { + let mut app = app_with_box(); + let mover = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.new.layer", json!({"name": "second"})).unwrap(); + app.run("select.rect", json!({"x": 120, "y": 40, "width": 40, "height": 40})).unwrap(); + app.run("edit.fill", json!({"color": "#00ff00"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + let second = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.select", json!({"layer": mover.0, "mode": "add"})).unwrap(); + app.ui.tool = Tool::Move; + crate::transform_tool::begin_layers(&mut app, vec![mover, second], [40.0, 40.0, 160.0, 80.0]).unwrap(); + assert_eq!(crate::transform_tool::moving_layers(&app).map(|l| l.len()), Some(2)); + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 51.0, y: 50.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 52.0, y: 50.0 }, m); + let q = app.ui.transform.as_ref().unwrap().quad; + assert!((q[0][0] - 42.0).abs() < 1e-6, "the box follows the pointer: {q:?}"); + // The snap still works against a layer that stays put (the target's left edge, x = 300). + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 306.0, y: 50.0 }, m); + let q = app.ui.transform.as_ref().unwrap().quad; + assert!((q[0][0] - 300.0).abs() < 1e-6, "{q:?}"); +} + #[test] fn free_transform_drags_and_handles_snap() { let mut app = app_with_box(); diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index d193bf6e00..e8edb5a8d6 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -113,6 +113,7 @@ fn filter_event_before_tool(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui:: // Move tool (⌘ with a painting tool too, `event_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()) + && app.ui.transform.is_none() && !crate::move_ui::moves_selected_pixels_with(app, event_tool(app, mods)) && selection_drag_kind(app, app.ui.tool, [x, y], mods) != Some(true) { diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index fccc2c9751..65d4037d0e 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -37,27 +37,60 @@ pub(super) fn transform_controls_layers(app: &PhotocraftApp) -> Vec<&photocraft_ return Vec::new(); } let Some(st) = app.session.active() else { return Vec::new() }; - st.selected_layers().into_iter().filter_map(|id| st.doc.layer(id)).filter(|l| !crate::doc_props_ui::is_background(&st.doc, l)).collect() + let layers: Vec<_> = + st.selected_layers().into_iter().filter_map(|id| st.doc.layer(id)).filter(|l| !crate::doc_props_ui::is_background(&st.doc, l)).collect(); + if layers.len() > 1 && layers.iter().any(|l| photocraft_engine::transform_cmds::locked_for_transform(&st.doc, l.id)) { + return Vec::new(); + } + layers } -/// Several layers are selected: Show Transform Controls frames them all, as Photoshop does, but -/// Free Transform works on one layer, so the frame has no handles to scale or rotate them. +/// Several layers are selected: Show Transform Controls frames and transforms them together. pub(super) fn transform_controls_several(app: &PhotocraftApp) -> bool { transform_controls_layers(app).len() > 1 } +/// The document-space frame behind Move › Show Transform Controls. Several selected layers are +/// transformed as whole layers, so a pixel selection does not crop every one to the selection. +fn transform_controls_bounds(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + let layers = transform_controls_layers(app); + let several = layers.len() > 1; + let floating = photocraft_engine::float_cmds::floating(st).map(|f| f.layer); + let floating_piece = app.session.active_index().and_then(|idx| floating_outline(app, idx)).map(|o| o.content_bounds()); + let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0)); + layers + .into_iter() + .map(|l| { + if several && floating == Some(l.id) { + let remaining = photocraft_engine::transform_cmds::whole_layer_bounds(&st.doc, l); + floating_piece.map_or(remaining, |piece| remaining.union(&piece.translate(dx, dy))) + } else if several { + photocraft_engine::transform_cmds::whole_layer_bounds(&st.doc, l) + } else { + photocraft_engine::transform_cmds::transform_frame(&st.doc, l) + } + }) + .filter(|b| !b.is_empty()) + .reduce(|a, b| a.union(&b)) +} + /// Move tool › Show Transform Controls: the boxes Free Transform would open on the selected /// layers, joined into one. pub(super) fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option { let st = app.session.active()?; let layers = transform_controls_layers(app); - let b = layers.iter().map(|l| photocraft_engine::transform_cmds::transform_frame(&st.doc, l)).filter(|b| !b.is_empty()).reduce(|a, b| a.union(&b))?; + let b = transform_controls_bounds(app)?; // Floating selected pixels are framed by their outline, as their ants are (#7538), not by the // selection they were cut with; a frame round other selected layers too keeps them all. let alone = matches!(layers.as_slice(), [l] if photocraft_engine::float_cmds::floating(st).is_some_and(|f| f.layer == l.id)); - let b = app.session.active_index().filter(|_| alone).and_then(|idx| floating_outline(app, idx)).map_or(b, |o| o.content_bounds()); - let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0)); - let b = b.translate(dx, dy); + let b = if alone { + let b = app.session.active_index().and_then(|idx| floating_outline(app, idx)).map_or(b, |o| o.content_bounds()); + let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0)); + b.translate(dx, dy) + } else { + b + }; if b.is_empty() { return None; } @@ -112,14 +145,18 @@ pub(super) fn transform_controls_hit(r: Rect, p: Pos2) -> bool { /// Enter the existing Free Transform session when a Move-tool transform control is pressed. pub(super) fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> bool { - if transform_controls_several(app) { - return false; - } let Some(r) = transform_controls_rect(app, xf) else { return false }; if !transform_controls_hit(r, p) { return false; } - match crate::transform_tool::begin(app, ctx) { + let result = if transform_controls_several(app) { + let Some(b) = transform_controls_bounds(app) else { return false }; + let ids = transform_controls_layers(app).into_iter().map(|l| l.id).collect(); + crate::transform_tool::begin_layers(app, ids, [b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64]) + } else { + crate::transform_tool::begin(app, ctx) + }; + match result { Ok(()) => true, Err(e) => { app.ui.status = e; @@ -128,8 +165,8 @@ pub(super) fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::C } } -/// Move tool › Show Transform Controls: the selected layers' bounding box, with its eight handles -/// for one layer, and the outline of every selected shape layer, as in Photoshop. +/// Move tool › Show Transform Controls: the selected layers' bounding box with its eight handles, +/// and the outline of every selected shape layer, as in Photoshop. pub(super) fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(r) = transform_controls_rect(app, xf) else { return }; let accent = crate::theme::Tokens::get(painter.ctx()).accent; @@ -140,9 +177,6 @@ pub(super) fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::P } } painter.rect_stroke(r, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Middle); - if transform_controls_several(app) { - return; - } for p in [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] { let h = Rect::from_center_size(p, vec2(7.0, 7.0)); painter.rect_filled(h, 0.0, Color32::WHITE); diff --git a/crates/ui-egui/src/canvas/transform_controls_tests.rs b/crates/ui-egui/src/canvas/transform_controls_tests.rs index 957ae3589d..9f84920a2d 100644 --- a/crates/ui-egui/src/canvas/transform_controls_tests.rs +++ b/crates/ui-egui/src/canvas/transform_controls_tests.rs @@ -108,6 +108,10 @@ fn a_floating_piece_among_several_selected_layers_keeps_the_shared_frame() { let mut app = floating_square(); app.session.execute("select.drop", json!({})).unwrap(); let a = app.session.active().unwrap().active_layer.unwrap(); + // Leave some pixels on the floating layer outside the later marquee. A shared frame must + // enclose both these stationary pixels and the translated floating piece. + app.session.execute("select.rect", json!({"x": 5, "y": 10, "width": 10, "height": 10})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); app.session.execute("layer.new.layer", json!({})).unwrap(); app.session.execute("select.rect", json!({"x": 150, "y": 150, "width": 20, "height": 20})).unwrap(); app.session.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); @@ -122,18 +126,46 @@ fn a_floating_piece_among_several_selected_layers_keeps_the_shared_frame() { ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; let r = transform_controls_rect(&app, &xf).unwrap(); let (lt, rb) = (xf.to_doc(r.left_top()), xf.to_doc(r.right_bottom())); - assert!(rb[0] - lt[0] > 60.0 && rb[1] - lt[1] > 60.0, "the frame isn't shrunk to the one piece: {lt:?} {rb:?}"); + assert_eq!( + [lt[0], lt[1], rb[0], rb[1]], + [5.0, 10.0, 170.0, 170.0], + "the frame encloses the remaining pixels, translated floating piece, and other selected layer" + ); + + let ctx = egui::Context::default(); + assert!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom())); + let shown = app.ui.transform.as_ref().unwrap().rect; + for (actual, expected) in shown.into_iter().zip([lt[0], lt[1], rb[0], rb[1]]) { + assert!((actual - expected).abs() < 1e-3, "the transform starts on the displayed shared frame: {shown:?}"); + } + + // The shared preview must start from the displayed floating document too. Otherwise the + // Move preview wins first, or the transformed clone is built from the cut-out document, + // and the floating pixels disappear while the shared box is moved. + let st = app.session.active().unwrap(); + let floating = photocraft_engine::float_cmds::floating(st).unwrap(); + let piece = floating.parts.outline().unwrap().content_bounds(); + let at = [(piece.x0 + piece.x1) / 2 + floating.offset.0, (piece.y0 + piece.y1) / 2 + floating.offset.1]; + let displayed = photocraft_engine::float_cmds::displayed(st, (0, 0)).unwrap(); + assert!(photocraft_compose::render(&displayed, displayed.bounds()).get(at[0], at[1])[3] > 0.5, "the floating piece is visible before transform"); + + for p in &mut app.ui.transform.as_mut().unwrap().quad { + p[0] += 60.0; + } + let (preview, _) = display_doc(&mut app, 0); + let rendered = photocraft_compose::render(&preview, preview.bounds()); + assert!(rendered.get(at[0] + 60, at[1])[3] > 0.5, "the floating piece follows the shared transform preview"); + assert!(rendered.get(at[0], at[1])[3] < 0.5, "the floating piece no longer stays at its untransformed preview position"); } #[test] -fn several_selected_layers_share_one_frame_without_handles() { - // As in Photoshop: Show Transform Controls frames every selected layer. Free Transform - // works on one layer, so the shared frame doesn't start it. +fn several_selected_layers_share_one_frame_with_handles_and_transform_session() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); app.session.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 40, 40], "fill": "#ff0000"})).unwrap(); let a = app.session.active().unwrap().active_layer.unwrap(); app.session.execute("shape.create", json!({"kind": "rect", "rect": [100, 120, 50, 30], "fill": "#ff0000"})).unwrap(); + let b = app.session.active().unwrap().active_layer.unwrap(); app.sync_views(); app.ui.tool = Tool::Move; app.ui.tool_options.move_show_transform = true; @@ -147,13 +179,61 @@ fn several_selected_layers_share_one_frame_without_handles() { assert!(both.contains_rect(one) && both.width() > one.width() && both.height() > one.height(), "{both:?} frames both"); let (lt, rb) = (xf.to_doc(both.left_top()), xf.to_doc(both.right_bottom())); assert!((lt[0] - 20.0).abs() < 1e-3 && (lt[1] - 30.0).abs() < 1e-3 && (rb[0] - 150.0).abs() < 1e-3 && (rb[1] - 150.0).abs() < 1e-3, "{lt:?} {rb:?}"); - let ctx = egui::Context::default(); - assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom())); - assert!(app.ui.transform.is_none()); - // The Background never joins the frame. + assert_eq!(transform_controls_cursor(both, both.right_bottom()), Some(egui::CursorIcon::ResizeNwSe)); + assert_eq!(transform_controls_cursor(both, both.center_top()), Some(egui::CursorIcon::ResizeVertical)); + + // The Background never joins the frame, even when selected too. let bg = app.session.active().unwrap().doc.layers[0].id; app.session.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); assert_eq!(transform_controls_layers(&app).len(), 2); + + // A shared handle now opens a real multi-layer Free Transform session. + let ctx = egui::Context::default(); + assert!(begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom())); + assert!(app.ui.transform.is_some()); + assert!(app.multi_transform.is_some()); + assert!(app.transform_preview.is_none(), "multi-layer preview uses a transformed document clone"); + assert_eq!(app.ui.transform.as_ref().unwrap().rect, [20.0, 30.0, 150.0, 150.0]); + + // Applying the shared box routes one transform to both selected layers. + app.background_jobs = false; + app.ui.transform.as_mut().unwrap().quad = [[30.0, 35.0], [160.0, 35.0], [160.0, 155.0], [30.0, 155.0]]; + crate::transform_tool::commit(&mut app); + assert!(app.ui.transform.is_none() && app.multi_transform.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(photocraft_engine::transform_cmds::whole_layer_bounds(&st.doc, st.doc.layer(a).unwrap()), photocraft_geom::Rect::new(30, 35, 70, 75)); + assert_eq!(photocraft_engine::transform_cmds::whole_layer_bounds(&st.doc, st.doc.layer(b).unwrap()), photocraft_geom::Rect::new(110, 125, 160, 155)); +} + +#[test] +fn shared_transform_controls_hide_if_any_selected_layer_is_locked() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 40, 40], "fill": "#ff0000"})).unwrap(); + let a = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("shape.create", json!({"kind": "rect", "rect": [100, 120, 50, 30], "fill": "#ff0000"})).unwrap(); + let b = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + app.session + .edit("lock", |doc, _| { + doc.layer_mut(b).unwrap().locks.position = true; + Ok(()) + }) + .unwrap(); + app.sync_views(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; + + assert!(transform_controls_layers(&app).is_empty(), "a shared box that cannot start must not be shown"); + assert!(transform_controls_rect(&app, &xf).is_none()); + + // Preserve the existing single-layer behaviour: a locked layer still shows its controls; + // pressing one reports the lock rather than silently removing the frame. + app.session.execute("layer.select", json!({"layer": b.0})).unwrap(); + assert_eq!(transform_controls_layers(&app).len(), 1); + assert!(transform_controls_rect(&app, &xf).is_some()); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 22d3565ffa..b145e8a02f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -524,6 +524,8 @@ pub struct PhotocraftApp { pub(crate) hist_uncached: Option<(DocId, u64)>, /// Free Transform preview (document without the moving pixels + their texture). pub(crate) transform_preview: Option, + /// Move-tool Show Transform Controls session spanning several selected layers. + pub(crate) multi_transform: Option, /// Move-tool ⇧/⌥ drag state (move_mods). pub(crate) move_mods: move_mods::MoveDrag, /// Cached document for a modal text Style Options color preview. diff --git a/crates/ui-egui/src/tools/transform_tool.rs b/crates/ui-egui/src/tools/transform_tool.rs index 672bd82868..f03b43ecf3 100644 --- a/crates/ui-egui/src/tools/transform_tool.rs +++ b/crates/ui-egui/src/tools/transform_tool.rs @@ -33,8 +33,12 @@ mod warp; pub(crate) use begin::begin_content_aware; pub use begin::{begin, begin_copy, begin_placed, begin_selection, begin_warp, enter_warp, leave_warp, warps_type}; -pub(crate) use commit::{SAVE_FOR_WEB, committing, nudge}; +pub(crate) use begin::{begin_layers, moving_layers}; +#[cfg(test)] +use commit::multi_selection_changed; +pub(crate) use commit::{SAVE_FOR_WEB, committing, multi_display_doc, nudge}; pub use commit::{cancel, commit, end_if_left, follow_edits, intercept, is_enabled, on_job_event, track_steps}; +use drag_modes::multi_has_text; #[cfg(test)] use drag_modes::{HandleIndicator, handle_indicator, keep_convex, turns}; pub(crate) use drag_modes::{apply_drag, corner_distorts, legacy_mods}; @@ -139,6 +143,23 @@ pub struct TransformPreview { pub(crate) content_aware: Option, } +/// A Free Transform geometry session started from the Move tool's shared controls for several +/// selected layers. Unlike [`TransformPreview`], the preview is a transformed clone of the whole +/// document, because there is no single moving texture to peel out and draw on a quad. +pub(crate) struct MultiTransform { + document: DocId, + revision: u64, + layers: Vec, + gesture: Option, + shown_quad: Option<[[f64; 2]; 4]>, + shown_interpolation: Option, + shown: Option>, + generation: u64, + tool: Tool, + /// A background `edit.transform` applying the shared transform. + committing: Option, +} + /// What [`follow_edits`] needs to rebuild the preview document. #[derive(Clone, Copy, Debug)] struct Follow { diff --git a/crates/ui-egui/src/tools/transform_tool/begin.rs b/crates/ui-egui/src/tools/transform_tool/begin.rs index 44bea250aa..cb9cd0f4a2 100644 --- a/crates/ui-egui/src/tools/transform_tool/begin.rs +++ b/crates/ui-egui/src/tools/transform_tool/begin.rs @@ -1,3 +1,4 @@ +use super::MultiTransform; use super::commit::{commit, start_steps}; use super::preview::{Above, preview_image}; use super::{ @@ -8,6 +9,9 @@ use super::{ /// Start Free Transform on the active layer (or its selected pixels), or on the targeted unlinked /// layer mask, alpha channel or Quick Mask. pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> { + if app.multi_transform.is_some() { + return Err("Commit or cancel the transform first".into()); + } if let Some((params, path)) = crate::vector_ui::free_transform_path(app) { return begin_path(app, ctx, params, path); } @@ -102,9 +106,82 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> Ok(()) } +/// The layers a shared transform box moves (none outside one): snapping must not target them. +pub(crate) fn moving_layers(app: &PhotocraftApp) -> Option> { + app.multi_transform.as_ref().map(|m| m.layers.clone()) +} + +/// Start one shared transform box for several selected layers from Move › Show Transform +/// Controls. Every layer is transformed by the same homography around `rect`; the engine commits +/// them together as one history step. This path deliberately does not use the single-layer lifted +/// texture preview: [`multi_display_doc`] previews a transformed document clone instead. +pub(crate) fn begin_layers(app: &mut PhotocraftApp, layers: Vec, rect: [f64; 4]) -> Result<(), String> { + if layers.len() < 2 { + return Err("select two or more layers".into()); + } + let st = app.session.active().ok_or("no document")?; + let doc = st.doc.clone(); + let revision = st.revision; + let active_layer = st.active_layer; + let mut ids = Vec::with_capacity(layers.len()); + for id in layers { + let Some(layer) = doc.layer(id) else { return Err("a selected layer no longer exists".into()) }; + if crate::doc_props_ui::is_background(&doc, layer) { + continue; + } + if photocraft_engine::transform_cmds::locked_for_transform(&doc, id) { + app.ui.open_dialog(crate::state::DialogKind::Error, json!({"message": tl!(LOCKED)}).as_object().cloned().unwrap_or_default()); + return Err(LOCKED.into()); + } + if !ids.contains(&id) { + ids.push(id); + } + } + if ids.len() < 2 { + return Err("select two or more transformable layers".into()); + } + crate::type_tool::commit(app); + let fallback = ids.first().copied().ok_or("select two or more transformable layers")?; + let active = active_layer.filter(|id| ids.contains(id)).unwrap_or(fallback); + let session = app.ui.alloc_id(); + app.transform_preview = None; + app.multi_transform = Some(MultiTransform { + document: doc.id, + revision, + layers: ids, + gesture: None, + shown_quad: None, + shown_interpolation: None, + shown: None, + generation: 0, + tool: app.ui.tool, + committing: None, + }); + app.ui.transform = Some(TransformSession { + session, + layer: active.0, + rect, + quad: corners(rect), + pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0], + interpolation: "bicubic".into(), + warp: None, + selection: false, + target: None, + path: None, + made: None, + mode: Default::default(), + content_aware: None, + skew_v: 0.0, + }); + Ok(()) +} + /// Free Transform on a copy (⌥⌘T, no menu item): duplicates the active layer (with a selection, /// its selected pixels, as Layer via Copy) and transforms the copy (#352). pub fn begin_copy(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> { + if app.multi_transform.is_some() { + return Err("Commit or cancel the transform first".into()); + } let selection = app.session.active().is_some_and(|d| d.doc.selection.is_some()); let params = if selection { json!({}) } else { json!({"inPlace": true}) }; app.run(if selection { "layer.new.layerViaCopy" } else { "layer.duplicate" }, params)?; @@ -402,6 +479,9 @@ pub(super) fn rect_to_quad_affine(r: [f64; 4], q: [[f64; 2]; 4]) -> Option