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 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-07 10:56:58 +07:00 committed by GitHub
parent 7b3d131bc8
commit d38eefc8f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 109 additions and 14 deletions

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -104,6 +104,7 @@
Puppet Warp パペットワープ
Perspective Warp 遠近法ワープ
Free Transform 自由変形
Free Transform a Copy コピーを自由変形
Transform 変形
Again もう一度
Transform Again on a Copy 複製してもう一度変形

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1554,6 +1554,7 @@
# Transform / warp overlays
Free Transform Свободное трансформирование
Free Transform a Copy Свободное трансформирование копии
Transform Трансформирование
Transform Selection Трансформировать выделение
Switch between free transform and warp modes Переключение между свободным трансформированием и деформаци

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -99,6 +99,7 @@
Puppet Warp 操控变形
Perspective Warp 透视变形
Free Transform 自由变换
Free Transform a Copy 自由变换副本
Transform 变换
Again 再次变换
Transform Again on a Copy 复制并再次变换

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -108,6 +108,7 @@
Puppet Warp 操控彎曲
Perspective Warp 透視彎曲
Free Transform 任意變形
Free Transform a Copy 任意變形拷貝
Transform 變形
Again 再次
Transform Again on a Copy 複製並再次變形

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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"

View File

@ -487,6 +487,10 @@ pub struct TransformSession {
/// the Quick Mask by itself (`None`: the layer, with its linked masks).
#[serde(default)]
pub target: Option<serde_json::Value>,
/// 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.

View File

@ -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();