From 0ef00d2b89ee8d66d9260887dc41c02b82a6270b Mon Sep 17 00:00:00 2001 From: eiketre <71788463+ekedal@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:09:13 +0200 Subject: [PATCH 1/9] fix(ui): add transform handles for multi-layer selection --- crates/engine/src/transform_cmds.rs | 169 +++++++++++++-- crates/ui-egui/src/canvas.rs | 88 +++++--- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/transform_tool.rs | 309 ++++++++++++++++++++++++++- docs/ui-parity.md | 6 +- 5 files changed, 521 insertions(+), 54 deletions(-) diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index fce8c1be49..a3321ebb36 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/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 @@ -732,6 +732,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); @@ -739,15 +740,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 => { @@ -755,10 +801,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())); @@ -788,17 +835,68 @@ 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 { + transform_layers_doc(doc, ids, &h, affine, interp, ctl) + } 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 outline therefore stays where it is; this matches the Move tool's existing +/// multi-layer move semantics. +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( @@ -859,7 +957,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"="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),"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"="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, @@ -893,6 +991,51 @@ mod tests { 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)); + } + + #[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/canvas.rs b/crates/ui-egui/src/canvas.rs index 5f1dd941c2..62e6196c67 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1201,6 +1201,10 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar if let Some(shown) = crate::move_ui::display_doc(app, idx) { return shown; } + // Move › Show Transform Controls on several selected layers: one shared live transform. + if let Some(shown) = crate::transform_tool::multi_display_doc(app, idx) { + return shown; + } // Patch Tool drag: the selection healed from where the pointer is. if let Some(shown) = crate::patch_preview::display_doc(app, idx) { return shown; @@ -4118,10 +4122,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // 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). @@ -4972,21 +4974,34 @@ fn transform_controls_layers(app: &PhotocraftApp) -> Vec<&photocraft_doc::Layer> st.selected_layers().into_iter().filter_map(|id| st.doc.layer(id)).filter(|l| !crate::doc_props_ui::is_background(&st.doc, l)).collect() } -/// 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. 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; + layers + .into_iter() + .map(|l| { + 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. fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option { - let st = app.session.active()?; - let b = transform_controls_layers(app) - .into_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)?; let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0)); let b = b.translate(dx, dy); if b.is_empty() { @@ -5043,14 +5058,18 @@ fn transform_controls_hit(r: Rect, p: Pos2) -> bool { /// Enter the existing Free Transform session when a Move-tool transform control is pressed. 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; @@ -5059,8 +5078,8 @@ fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: } } -/// 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. 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; @@ -5071,9 +5090,6 @@ fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: } } 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); @@ -8570,14 +8586,13 @@ mod transform_controls_tests { use serde_json::json; #[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; @@ -8591,13 +8606,30 @@ mod transform_controls_tests { 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] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f2e795430c..2f599cdbe4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -813,6 +813,8 @@ pub struct PhotocraftApp { pub(crate) hist_job: Option, /// 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. @@ -1048,6 +1050,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/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 65a5c70146..75945f3d59 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -93,6 +93,22 @@ 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: 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 { @@ -284,6 +300,70 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> Ok(()) } +/// 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: 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> { @@ -646,6 +726,9 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( /// Start (or switch an active Free Transform into) Warp mode. pub fn begin_warp(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> { + if app.multi_transform.is_some() { + return Err("Warp is not available for a multi-layer transform".into()); + } if app.ui.transform.as_ref().is_some_and(|t| t.content_aware.is_some()) { return Err(CAS_ONLY_SCALES.into()); } @@ -665,6 +748,10 @@ pub fn begin_warp(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), St /// custom mesh following the current box (so a transform made first carries over). Editable type /// gets its own Warp Text style instead ([`enter_type_warp`]). pub fn enter_warp(app: &mut PhotocraftApp, ctx: &egui::Context) { + if app.multi_transform.is_some() { + app.ui.status = "Warp is not available for a multi-layer transform".into(); + return; + } if app.ui.transform.as_ref().is_some_and(|t| t.warp.is_none()) && warps_type(app) { return enter_type_warp(app, ctx); } @@ -696,6 +783,9 @@ pub fn enter_warp(app: &mut PhotocraftApp, ctx: &egui::Context) { /// The open transform moves an editable type layer as a whole (not a lone mask, channel, path or /// the selection). Its Warp is Warp Text: only the styles, and the type stays editable (#3004). pub fn warps_type(app: &PhotocraftApp) -> bool { + if app.multi_transform.is_some() { + return false; + } let Some(t) = &app.ui.transform else { return false }; if t.selection || t.target.is_some() || t.path.is_some() { return false; @@ -1021,7 +1111,7 @@ pub fn commit(app: &mut PhotocraftApp) { if committing(app).is_some() { return; } - let document = app.transform_preview.as_ref().map(|pv| pv.doc.id); + let document = app.transform_preview.as_ref().map(|pv| pv.doc.id).or_else(|| app.multi_transform.as_ref().map(|m| m.document)); if document.is_some_and(|id| app.session.active().is_none_or(|st| st.doc.id != id)) { cancel(app); return; @@ -1029,6 +1119,41 @@ pub fn commit(app: &mut PhotocraftApp) { let type_layer = warps_type(app); let Some(t) = app.ui.transform.take() else { return }; let mut preview = app.transform_preview.take(); + let mut multi = app.multi_transform.take(); + if let Some(mut m) = multi.take() { + if t.warp.is_some() { + app.ui.transform = Some(t); + app.multi_transform = Some(m); + app.ui.status = "Warp is not available for a multi-layer transform".into(); + return; + } + if t.quad == corners(t.rect) { + return; + } + let r = app.run( + "edit.transform", + json!({ + "layers": m.layers.iter().map(|id| id.0).collect::>(), + "rect": t.rect, + "quad": t.quad, + "interpolation": t.interpolation, + }), + ); + if let Ok(v) = &r + && let Some(job) = v.get("pending").and_then(serde_json::Value::as_bool).filter(|p| *p).and(v.get("job")).and_then(serde_json::Value::as_u64) + { + m.committing = Some(JobId(job)); + app.ui.transform = Some(t); + app.multi_transform = Some(m); + return; + } + if let Err(e) = r { + app.ui.transform = Some(t); + app.multi_transform = Some(m); + app.ui.status = e; + } + return; + } if let Some(options) = &t.content_aware { crate::cas_transform::commit(app, &t, options); return; @@ -1112,7 +1237,7 @@ fn finish(app: &mut PhotocraftApp, document: Option, layer: LayerId, made /// The job applying the transform, while it runs. pub(crate) fn committing(app: &PhotocraftApp) -> Option { - app.transform_preview.as_ref().and_then(|pv| pv.committing) + app.transform_preview.as_ref().and_then(|pv| pv.committing).or_else(|| app.multi_transform.as_ref().and_then(|m| m.committing)) } /// A job ended (see [`crate::jobs_ui`]): when it was the commit, the session ends with it. Its @@ -1121,6 +1246,19 @@ pub fn on_job_event(app: &mut PhotocraftApp, e: &JobEvent) { if committing(app) != Some(e.id) { return; } + if app.multi_transform.as_ref().is_some_and(|m| m.committing == Some(e.id)) { + let Some(t) = app.ui.transform.take() else { return }; + let Some(mut m) = app.multi_transform.take() else { return }; + if let JobOutcome::Failed(msg) = &e.outcome { + app.ui.status = msg.clone(); + if m.tool == app.ui.tool { + m.committing = None; + app.ui.transform = Some(t); + app.multi_transform = Some(m); + } + } + return; + } let (Some(t), Some(mut pv)) = (app.ui.transform.take(), app.transform_preview.take()) else { return }; // A refused commit (a result over the pixel budget, #3773) keeps the box open, as inline. if let JobOutcome::Failed(msg) = &e.outcome { @@ -1152,6 +1290,27 @@ pub fn end_if_left(app: &mut PhotocraftApp) { } let Some(st) = app.session.active() else { return cancel(app) }; + if let Some(m) = app.multi_transform.as_ref() { + let switched_document = st.doc.id != m.document; + let missing = m.layers.iter().any(|id| st.doc.layer(*id).is_none()); + let switched_tool = m.tool != app.ui.tool; + let selected = st + .selected_layers() + .into_iter() + .filter(|id| st.doc.layer(*id).is_some_and(|l| !crate::doc_props_ui::is_background(&st.doc, l))) + .collect::>(); + let changed_selection = selected.len() != m.layers.len() || m.layers.iter().any(|id| !selected.contains(id)); + if switched_document || missing { + cancel(app); + } else if switched_tool || changed_selection { + commit(app); + if app.ui.transform.is_some() && committing(app).is_none() { + cancel(app); + } + } + return; + } + let layer = LayerId(t.layer); let made = t.made; let selection = t.selection; @@ -1179,6 +1338,60 @@ pub fn end_if_left(app: &mut PhotocraftApp) { } } +/// Document shown while a shared Move-tool transform is open. The engine applies the current +/// quad to a clone, so all selected layers preview together while the source document stays +/// untouched until [`commit`]. The clone is rebuilt only when the quad or source revision changes. +pub(crate) fn multi_display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u64)> { + if app.session.active_index() != Some(idx) { + return None; + } + let t = app.ui.transform.as_ref()?.clone(); + let st = app.session.documents().get(idx)?; + let (doc_id, revision, source) = (st.doc.id, st.revision, st.doc.clone()); + let (document, ids, old_revision, shown_quad, committing) = { + let m = app.multi_transform.as_ref()?; + (m.document, m.layers.clone(), m.revision, m.shown_quad, m.committing) + }; + if document != doc_id { + return None; + } + if t.quad == corners(t.rect) && committing.is_none() { + if let Some(m) = app.multi_transform.as_mut() { + m.shown = None; + m.shown_quad = None; + m.revision = revision; + } + return None; + } + if committing.is_some() { + let m = app.multi_transform.as_ref()?; + return m.shown.as_ref().map(|doc| { + let key = (1_u64 << 62) ^ t.session.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ m.generation; + (doc.clone(), key) + }); + } + let fresh = old_revision == revision && shown_quad == Some(t.quad); + if !fresh { + let shown = match photocraft_engine::transform_cmds::preview_layers(&source, &ids, t.rect, t.quad, &t.interpolation) { + Ok(doc) => Some(Arc::new(doc)), + Err(e) => { + log::warn!("multi-layer transform preview: {e}"); + None + } + }; + if let Some(m) = app.multi_transform.as_mut() { + m.revision = revision; + m.shown_quad = Some(t.quad); + m.shown = shown; + m.generation = m.generation.wrapping_add(1); + } + } + let m = app.multi_transform.as_ref()?; + let shown = m.shown.clone()?; + let key = (1_u64 << 62) ^ t.session.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ m.generation; + Some((shown, key)) +} + /// Edits made while the box is open show in the preview at once, not only after the commit /// (#1384): the moving layer's opacity, fill and visibility tint the moving pixels, and the /// document under them (other layers, and the part of the layer a selection leaves behind) is @@ -1210,6 +1423,7 @@ pub fn cancel(app: &mut PhotocraftApp) { crate::jobs_ui::cancel(app, job); return; } + app.multi_transform = None; let transform = app.ui.transform.take(); let preview = app.transform_preview.take(); if let (Some(t), Some(pv)) = (transform, preview) @@ -1282,6 +1496,9 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { /// Does the box move an editable type layer (not its selection outline, a lone mask or a path)? fn moves_type(app: &PhotocraftApp, t: &TransformSession) -> bool { + if app.multi_transform.is_some() { + return multi_has_text(app); + } !t.selection && t.target.is_none() && t.path.is_none() @@ -1418,6 +1635,49 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> warp_pointer(app, ev, tol, mods); return true; } + + if app.multi_transform.is_some() { + match ev { + ToolEvent::Down { x, y, .. } => { + let mut h = hit(&t, [x, y], tol); + // ⌥-click away from the handles moves the reference point there (and drags it). + if mods.alt + && matches!(h, Hit::Inside | Hit::Outside) + && let Some(s) = app.ui.transform.as_mut() + { + s.pivot = [x, y]; + h = Hit::Pivot; + } + let g = (mode_allows(t.mode, h) || h == Hit::Outside).then_some(Gesture { hit: h, start: [x, y], quad0: t.quad, pivot0: t.pivot }); + if let Some(m) = app.multi_transform.as_mut() { + m.gesture = g; + } + } + ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => { + let g = app.multi_transform.as_ref().and_then(|m| m.gesture); + if matches!(ev, ToolEvent::Up { .. }) + && let Some(m) = app.multi_transform.as_mut() + { + m.gesture = None; + } + if matches!(ev, ToolEvent::Up { .. }) && g.is_some_and(|g| accepts_outside_click(g, [x, y], tol)) { + commit(app); + return true; + } + let type_layer = multi_has_text(app); + let adjusted = g.map(|g| drag_mods(app, &t, g.hit, mods)); + if let (Some(g), Some((mode, mods)), Some(s)) = (g, adjusted, app.ui.transform.as_mut()) + && mode_allows(t.mode, g.hit) + { + apply_mode_drag(s, g, [x, y], mode, type_layer, mods); + round_to_pixel_grid(app); + } + } + } + crate::snap_ui::retain_transform_lines(app, mods); + return true; + } + let Some(pv) = app.transform_preview.as_mut() else { return false }; match ev { ToolEvent::Down { x, y, .. } => { @@ -1476,6 +1736,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> /// offset, and a proportional scale off by its ratio: a shape dragged to 540 px came out 539.6. /// Rotated, skewed and distorted boxes are left as dragged. fn round_to_pixel_grid(app: &mut PhotocraftApp) { + if app.multi_transform.is_some() { + return; + } if !app.session.prefs().tools.snap_vector_tools_and_transforms_to_pixel_grid { return; } @@ -1527,11 +1790,17 @@ pub(crate) fn corner_distorts(app: &PhotocraftApp, index: usize, mods: egui::Mod /// Editable type can be scaled, rotated and skewed but not distorted (#2630): the rule that greys /// Distort and Perspective in `enable_rules::disabled`, for the box's own layer. A box on a path, /// on the selection outline or on a lone mask or channel moves no type. +fn multi_has_text(app: &PhotocraftApp) -> bool { + let (Some(st), Some(m)) = (app.session.active(), app.multi_transform.as_ref()) else { return false }; + m.layers.iter().any(|id| st.doc.layer(*id).is_some_and(|l| matches!(l.content, LayerContent::Text(_)))) +} + fn distort_refused(app: &PhotocraftApp, t: &TransformSession) -> bool { t.path.is_none() && !t.selection && t.target.is_none() - && app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Text(_))) + && (multi_has_text(app) + || app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Text(_)))) } /// Where Distort and Perspective are refused ([`distort_refused`]), ⌘-drag and ⌘⌥⇧-drag of a @@ -2055,10 +2324,16 @@ fn rotation_degrees(quad: [[f64; 2]; 4]) -> f64 { /// just outside a corner, or anywhere while a rotate drag is on. It points into the box along the /// bisector of the nearest corner, so each corner gets its own arrow and they turn with the box. pub(crate) fn rotate_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64; 2]> { - rotate_dir(app.ui.transform.as_ref()?, app.transform_preview.as_ref(), p, handle_tolerance(app)) + let rotating = app + .transform_preview + .as_ref() + .and_then(|pv| pv.gesture) + .or_else(|| app.multi_transform.as_ref().and_then(|m| m.gesture)) + .is_some_and(|g| g.hit == Hit::Outside); + rotate_dir(app.ui.transform.as_ref()?, rotating, p, handle_tolerance(app)) } -fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: [f64; 2], tol: f64) -> Option<[f64; 2]> { +fn rotate_dir(t: &TransformSession, rotating: bool, p: [f64; 2], tol: f64) -> Option<[f64; 2]> { // Content-Aware Scale's box only scales, and Skew doesn't rotate: no rotate arrow. if !(p[0].is_finite() && p[1].is_finite()) || t.warp.is_some() @@ -2067,7 +2342,6 @@ fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: [f64; 2], { return None; } - let rotating = pv.is_some_and(|pv| pv.gesture.is_some_and(|g| g.hit == Hit::Outside)); if !rotating && !near_rotate_corner(t, p, tol) { return None; } @@ -2200,9 +2474,14 @@ pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform /// The box, handles, reference point and path outline over the preview that [`draw_content`] /// drew; the warp grid and its split guides for a warp. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { - let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) else { return }; + let Some(t) = &app.ui.transform else { return }; + let pv = app.transform_preview.as_ref(); + if pv.is_none() && app.multi_transform.is_none() { + return; + } let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); if let Some(w) = &t.warp { + let Some(pv) = pv else { return }; let over_canvas = painter.ctx().input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)).map(|p| xf.to_doc(p)); let at = pv.split_pointer.or(over_canvas); let alt = painter.ctx().input(|i| i.modifiers.alt || i.modifiers.command); @@ -2218,11 +2497,13 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform draw_warp_overlay(painter, xf, w, pv, guide); return; } - if pv.committing.is_some() { + if committing(app).is_some() { return; } let accent = crate::theme::Tokens::get(painter.ctx()).accent; - if let (Some(path), Some(h)) = (&pv.path, Homography::rect_to_quad(t.rect, t.quad)) { + if let Some(pv) = pv + && let (Some(path), Some(h)) = (&pv.path, Homography::rect_to_quad(t.rect, t.quad)) + { crate::vector_ui::draw_outline( painter, path, @@ -2250,7 +2531,13 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some(at) = painter.ctx().pointer_hover_pos().filter(|p| xf.rect.contains(*p)) { let p = xf.to_doc(at); let tol = HANDLE_PX / f64::from(xf.zoom.max(0.01)); - if let Some(k) = rotate_dir(t, Some(pv), p, tol) { + let rotating = app + .transform_preview + .as_ref() + .and_then(|pv| pv.gesture) + .or_else(|| app.multi_transform.as_ref().and_then(|m| m.gesture)) + .is_some_and(|g| g.hit == Hit::Outside); + if let Some(k) = rotate_dir(t, rotating, p, tol) { // Through the view, so the arc bends round the corner as it shows under a turned or // flipped view. let toward = xf.to_screen((p[0] + k[0] * 16.0) as f32, (p[1] + k[1] * 16.0) as f32) - at; @@ -2369,7 +2656,7 @@ pub(crate) enum Readout { /// reference point, a warp, or a distortion that has no single scale). pub(crate) fn live_readout(app: &PhotocraftApp) -> Option { let t = app.ui.transform.as_ref().filter(|t| t.warp.is_none())?; - let g = app.transform_preview.as_ref()?.gesture?; + let g = app.transform_preview.as_ref().and_then(|pv| pv.gesture).or_else(|| app.multi_transform.as_ref().and_then(|m| m.gesture))?; match g.hit { Hit::Outside => Some(Readout::Angle(rotation_degrees(t.quad))), Hit::Inside => Some(Readout::Offset([t.quad[0][0] - g.quad0[0][0], t.quad[0][1] - g.quad0[0][1]])), diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 59d05d1e40..819546024d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -96,7 +96,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | ⌥-drag duplicates, ⇧ constrains to 45° | done | `move_mods.rs` | | Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 | | Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) | -| Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | partial: the shared frame has no handles, since Free Transform works on one layer (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_without_handles`; `move_auto_select_tests.rs`) | | +| Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | done: the shared frame has eight transform handles and opens one multi-layer Free Transform; scale/rotate/skew are previewed and committed to all selected top-level layers in one history step (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_with_handles_and_transform_session`; `transform_tool.rs`; `transform_cmds.rs`) | | | ⌘-drag temporary Move shows the box and snaps | partial | #2681 | | ⌘-right-click layer list under the pointer | done | `layer_pick_ui.rs` | | Double-click type to edit it (caret at the pointer, Type tool) | done | #1132, `type_tool::edit_at` | @@ -262,3 +262,5 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | + +| 2026-10-22 | minor | Move › Show Transform Controls: multi-layer selections now show all eight handles and transform the selected top-level layers together in one Free Transform/history step | From 205b33202980f3304edde72146f29e9ea8a48dc9 Mon Sep 17 00:00:00 2001 From: eiketre <71788463+ekedal@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:59:54 +0200 Subject: [PATCH 2/9] fix(ui): address multi-layer transform review --- crates/ui-egui/src/canvas.rs | 38 +++++++++++++++++++++++++++- crates/ui-egui/src/transform_tool.rs | 30 ++++++++++++++++++++++ docs/ui-parity.md | 3 +-- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 62e6196c67..d47779fd52 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4971,7 +4971,12 @@ fn transform_controls_layers(app: &PhotocraftApp) -> Vec<&photocraft_doc::Layer> 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 and transforms them together. @@ -8632,6 +8637,37 @@ mod transform_controls_tests { 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] fn move_transform_controls_start_free_transform_for_vector_shapes() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 75945f3d59..a3c14d6d5c 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -206,6 +206,9 @@ fn corners(r: [f64; 4]) -> [[f64; 2]; 4] { /// 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); } @@ -367,6 +370,9 @@ pub(crate) fn begin_layers(app: &mut PhotocraftApp, layers: Vec, rect: /// 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)?; @@ -4412,6 +4418,30 @@ mod tests { assert_eq!(app.session.active().unwrap().doc.layers.len(), layers); } + #[test] + fn free_transform_copy_rejects_an_open_multi_transform_without_making_a_layer() { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let first = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + let second = app.session.active().unwrap().active_layer.unwrap(); + app.session + .edit("paint second", |doc, _| { + doc.layer_mut(second).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(32, 8, 48, 24), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + begin_layers(&mut app, vec![first, second], [8.0, 8.0, 48.0, 24.0]).unwrap(); + let before = { + let st = app.session.active().unwrap(); + (st.doc.layers.len(), st.revision, st.history.past_len()) + }; + + assert_eq!(begin_copy(&mut app, &egui::Context::default()), Err("Commit or cancel the transform first".into())); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.layers.len(), st.revision, st.history.past_len()), before, "a rejected start is mutation-free"); + assert!(app.multi_transform.is_some() && app.ui.transform.is_some(), "the existing transform stays open"); + } + /// #1099: cancel, frame cleanup and a commit before the next frame all belong to the /// original document, including after tab reordering and with shared layer IDs. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 819546024d..a043823f42 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -96,7 +96,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | ⌥-drag duplicates, ⇧ constrains to 45° | done | `move_mods.rs` | | Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 | | Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) | -| Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | done: the shared frame has eight transform handles and opens one multi-layer Free Transform; scale/rotate/skew are previewed and committed to all selected top-level layers in one history step (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_with_handles_and_transform_session`; `transform_tool.rs`; `transform_cmds.rs`) | | +| Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | done | The shared frame has eight transform handles and opens one multi-layer Free Transform; scale/rotate/skew are previewed and committed to all selected top-level layers in one history step (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_with_handles_and_transform_session`; `transform_tool.rs`; `transform_cmds.rs`) | | ⌘-drag temporary Move shows the box and snaps | partial | #2681 | | ⌘-right-click layer list under the pointer | done | `layer_pick_ui.rs` | | Double-click type to edit it (caret at the pointer, Type tool) | done | #1132, `type_tool::edit_at` | @@ -262,5 +262,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | - | 2026-10-22 | minor | Move › Show Transform Controls: multi-layer selections now show all eight handles and transform the selected top-level layers together in one Free Transform/history step | From 20cba098e7a6eb53081d9a073890d7cd1eb8921c Mon Sep 17 00:00:00 2001 From: eiketre <71788463+ekedal@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:01:03 +0200 Subject: [PATCH 3/9] fix(ui): address multi-layer transform review --- crates/ui-egui/src/canvas.rs | 9 ++++ crates/ui-egui/src/transform_tool.rs | 61 ++++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3efc93ca7b..6d772a9b28 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5447,6 +5447,8 @@ fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: } let result = if transform_controls_several(app) { let Some(b) = transform_controls_bounds(app) else { return false }; + let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0)); + let b = b.translate(dx, dy); 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 { @@ -9429,6 +9431,13 @@ mod transform_controls_tests { 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:?}"); + + 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:?}"); + } } #[test] diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index bb6868ce16..ea83b7fa71 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -669,6 +669,9 @@ fn rect_to_quad_affine(r: [f64; 4], q: [[f64; 2]; 4]) -> Option