From d38eefc8f7c6ab31e3674da2e7a233652ecb7423 Mon Sep 17 00:00:00 2001 From: BFlat Date: Wed, 7 Oct 2026 10:56:58 +0700 Subject: [PATCH] Free Transform a Copy (Cmd+Alt+T); Esc takes the copy back (part of #352) (#482) The other hidden "quick transform" shortcut. Cmd+Alt+T (no menu item, in Keyboard Shortcuts as "Free Transform a Copy") duplicates the active layer, or with a selection copies the selected pixels to a new layer (Layer via Copy), and opens Free Transform on the copy. OK leaves the original in place and the transformed copy as one history step (the duplicate's state is folded away); an untouched OK keeps the copy where it is; Esc, or a transform that fails, undoes the copy and leaves nothing to redo. `TransformSession::copy` marks such a session. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus.rs | 3 + crates/ui-egui/src/state.rs | 4 + crates/ui-egui/src/transform_tool.rs | 110 +++++++++++++++++++++++---- 9 files changed, 109 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6de12a31..3a36e857 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -108,6 +108,7 @@ Puppet Warp Loutková deformace Perspective Warp Perspektivní deformace Free Transform Volná transformace + Free Transform a Copy Volná transformace kopie Transform Transformace Again Opakovat Transform Again on a Copy Duplikovat a opakovat transformaci diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cfac9c38..5147ad93 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -100,6 +100,7 @@ Puppet Warp Deformación de clavos Perspective Warp Deformación en perspectiva Free Transform Transformación libre + Free Transform a Copy Transformación libre de una copia Transform Transformar Again Repetir Transform Again on a Copy Duplicar y repetir la transformación diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d9d11113..242e5587 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -104,6 +104,7 @@ Puppet Warp パペットワープ Perspective Warp 遠近法ワープ Free Transform 自由変形 + Free Transform a Copy コピーを自由変形 Transform 変形 Again もう一度 Transform Again on a Copy 複製してもう一度変形 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ca3701fe..10551d16 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1554,6 +1554,7 @@ # Transform / warp overlays Free Transform Свободное трансформирование + Free Transform a Copy Свободное трансформирование копии Transform Трансформирование Transform Selection Трансформировать выделение Switch between free transform and warp modes Переключение между свободным трансформированием и деформаци diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7c029124..41027088 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -99,6 +99,7 @@ Puppet Warp 操控变形 Perspective Warp 透视变形 Free Transform 自由变换 + Free Transform a Copy 自由变换副本 Transform 变换 Again 再次变换 Transform Again on a Copy 复制并再次变换 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e0b6a53e..f8b01436 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -108,6 +108,7 @@ Puppet Warp 操控彎曲 Perspective Warp 透視彎曲 Free Transform 任意變形 + Free Transform a Copy 任意變形拷貝 Transform 變形 Again 再次 Transform Again on a Copy 複製並再次變形 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1937656c..7dbec134 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -31,6 +31,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("view.extras", "Extras", &["View"], Some("Cmd+H")), ("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")), ("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")), + ("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")), ("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")), ("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")), ("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")), @@ -369,6 +370,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: | "edit.transform.skew" | "edit.transform.distort" | "edit.transform.perspective" => crate::transform_tool::begin(app, ctx).map(|_| json!({"transform": app.ui.transform})), + "edit.freeTransformCopy" => crate::transform_tool::begin_copy(app, ctx).map(|_| json!({"transform": app.ui.transform})), // Edit › Transform › Warp from the menu: interactive Warp mode (with params: the engine). "edit.transform.warp" | "layer.smartObjects.warp" if params.as_object().is_none_or(|o| o.is_empty()) => { crate::transform_tool::begin_warp(app, ctx).map(|_| json!({"transform": app.ui.transform})) @@ -476,6 +478,7 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { app.session.active().is_some() } "edit.freeTransform" + | "edit.freeTransformCopy" | "edit.transform.scale" | "edit.transform.rotate" | "edit.transform.skew" diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c28f0764..6f5f8ceb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -487,6 +487,10 @@ pub struct TransformSession { /// the Quick Mask by itself (`None`: the layer, with its linked masks). #[serde(default)] pub target: Option, + /// Free Transform on a copy (⌥⌘T): the copy was made for this session, so Cancel takes it back + /// and OK folds it into the transform's history step (#352). + #[serde(default)] + pub copy: bool, } /// In-progress inline type editing (Type tool). Offsets are character indices. diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index f9f02d93..a5595240 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -85,10 +85,42 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> warp: None, selection: false, target: None, + copy: false, }); 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> { + let selection = app.session.active().is_some_and(|d| d.doc.selection.is_some()); + app.run(if selection { "layer.new.layerViaCopy" } else { "layer.duplicate" }, json!({}))?; + if let Err(e) = begin(app, ctx) { + take_back_copy(app); + return Err(e); + } + if let Some(t) = app.ui.transform.as_mut() { + t.copy = true; + } + Ok(()) +} + +/// Undoes the copy a cancelled or failed ⌥⌘T made, leaving nothing to redo. +fn take_back_copy(app: &mut PhotocraftApp) { + app.session.undo(); + if let Some(st) = app.session.active_mut() { + st.history.clear_redo(); + } + app.sync_views(); +} + +/// After ⌥⌘T's transform: the copy and the transform become one history step (the transform's). +fn fold_copy(app: &mut PhotocraftApp) { + if let Some(st) = app.session.active_mut() { + st.history.purge_last(); + } +} + /// Free Transform of a targeted unlinked layer mask, alpha channel or Quick Mask by itself: the /// box frames its content (within the selection) and previews the moving values in grey over the /// document with them vacated. @@ -138,6 +170,7 @@ fn begin_lone( warp: None, selection: false, target: Some(target), + copy: false, }); Ok(()) } @@ -181,6 +214,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( warp: None, selection: true, target: None, + copy: false, }); Ok(()) } @@ -305,30 +339,40 @@ pub fn commit(app: &mut PhotocraftApp) { } return; } - if let Some(w) = &t.warp { + let copy = t.copy; + let r = if let Some(w) = &t.warp { if w.is_identity() { return; } - if let Err(e) = app.run("edit.transform.warp", json!({"layer": t.layer, "rect": t.rect, "warp": w, "interpolation": t.interpolation})) { + app.run("edit.transform.warp", json!({"layer": t.layer, "rect": t.rect, "warp": w, "interpolation": t.interpolation})) + } else { + if t.quad == corners(t.rect) { + return; // untouched: nothing to do and no history step; a ⌥⌘T copy stays + } + let mut p = json!({"layer": t.layer, "rect": t.rect, "quad": t.quad, "interpolation": t.interpolation}); + if let Some(target) = t.target { + p["target"] = target; + } + app.run("edit.transform", p) + }; + match r { + Ok(_) if copy => fold_copy(app), + Ok(_) => {} + Err(e) => { + if copy { + take_back_copy(app); + } app.ui.status = e; } - return; - } - if t.quad == corners(t.rect) { - return; // untouched: nothing to do (Photoshop adds no history step either) - } - let mut p = json!({"layer": t.layer, "rect": t.rect, "quad": t.quad, "interpolation": t.interpolation}); - if let Some(target) = t.target { - p["target"] = target; - } - if let Err(e) = app.run("edit.transform", p) { - app.ui.status = e; } } pub fn cancel(app: &mut PhotocraftApp) { - app.ui.transform = None; + let copy = app.ui.transform.take().is_some_and(|t| t.copy); app.transform_preview = None; + if copy { + take_back_copy(app); + } } /// Which part of the box a document point hits. @@ -1000,6 +1044,7 @@ mod tests { warp: None, selection: false, target: None, + copy: false, } } @@ -1078,6 +1123,43 @@ mod tests { assert!(app.ui.transform.is_none() && app.transform_preview.is_none()); } + /// #352: ⌥⌘T transforms a copy. OK leaves the original and a moved copy as one history step; + /// Cancel leaves no copy and nothing to redo; with a selection the copy holds the selected + /// pixels only. + #[test] + fn free_transform_a_copy() { + let ctx = egui::Context::default(); + let bounds = |app: &PhotocraftApp, i: usize| app.session.active().unwrap().doc.layers[i].surface().unwrap().content_bounds(); + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let (layers, steps) = (app.session.active().unwrap().doc.layers.len(), app.session.active().unwrap().history.past_len()); + crate::menus::invoke(&mut app, &ctx, "edit.freeTransformCopy", json!({})).unwrap(); + assert!(app.ui.transform.as_ref().unwrap().copy); + assert!(!crate::menus::is_enabled(&app, "edit.freeTransformCopy"), "one transform at a time"); + // Esc: the copy goes too. + cancel(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.layers.len(), st.history.past_len()), (layers, steps)); + assert!(!st.history.can_redo(), "nothing to redo"); + // OK: original in place, a moved copy, one step. + crate::menus::invoke(&mut app, &ctx, "edit.freeTransformCopy", json!({})).unwrap(); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [x + 20.0, y]); + } + commit(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.layers.len(), st.history.past_len()), (layers + 1, steps + 1)); + assert_eq!(bounds(&app, layers - 1), photocraft_geom::Rect::new(8, 8, 24, 24), "the original stays"); + assert_eq!(bounds(&app, layers), photocraft_geom::Rect::new(28, 8, 44, 24), "the copy moved"); + app.session.undo(); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "one undo removes copy and move"); + // With a selection: Layer via Copy, so only the selected pixels are copied. + app.run("select.rect", json!({"x": 8, "y": 8, "width": 8, "height": 16})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "edit.freeTransformCopy", json!({})).unwrap(); + assert_eq!(app.ui.transform.as_ref().unwrap().rect, [8.0, 8.0, 16.0, 24.0]); + cancel(&mut app); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers); + } + fn app_with_square(size: u32, fill: photocraft_geom::Rect) -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", json!({"width": size, "height": size})).unwrap();