diff --git a/crates/engine/src/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index f0d7150567..cf9004bbac 100644 --- a/crates/engine/src/cmd/transform_cmds.rs +++ b/crates/engine/src/cmd/transform_cmds.rs @@ -272,9 +272,9 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { } /// The layers Free Transform moves with the Layers panel's selection, bottom-to-top: with several -/// layers selected, every one that no selected group already carries, as in Photoshop (#6501), but -/// the Background, which Show Transform Controls doesn't frame either. With one layer selected (or -/// only the Background besides it), that layer; the active layer when nothing else is. +/// layers selected, every one that no selected group already carries, as in Photoshop (#6501). The +/// Background stays among them, so such a box refuses as locked (#11101, [`background_among`]). +/// With one layer selected, that layer; the active layer when nothing else is. pub fn selection_targets(st: &crate::DocState) -> Vec { targets_of(&st.doc, &st.selected_layers(), st.active_layer) } @@ -285,14 +285,23 @@ pub(crate) fn targets_of(doc: &Document, selected: &[LayerId], active: Option = crate::layer_multi_cmds::top_level(doc, selected).into_iter().filter(|id| !doc.is_background(*id)).collect(); + let ids = crate::layer_multi_cmds::top_level(doc, selected); if ids.is_empty() { active.into_iter().collect() } else { ids } } +/// The Background when it is among several layers one box would move: Photoshop refuses that Free +/// Transform with its locked-layer alert rather than leaving the Background out (#11101). +pub fn background_among(doc: &Document, ids: &[LayerId]) -> Option { + if ids.len() < 2 { + return None; + } + ids.iter().copied().find(|id| doc.is_background(*id)) +} + /// The layers one box over `targets` moves: `targets` with the layers linked to them, as the Move /// tool takes them along. In Photoshop transforming one linked layer transforms the whole link -/// group (#11217). A linked Background joins only when it is a target itself: `targets_of` -/// leaves it out of a box over other layers, and a link doesn't bring it back in. +/// group (#11217). A linked Background joins only when it is a target itself: a link doesn't +/// bring it into a box over other layers. pub fn box_targets(doc: &Document, targets: &[LayerId]) -> Vec { let mut ids = crate::layer_multi_cmds::move_targets(doc, targets); ids.retain(|id| targets.contains(id) || !doc.is_background(*id)); @@ -874,7 +883,14 @@ fn transform(s: &mut Session, p: &Value) -> Result { (Some(ids), _) => ids, _ if lone.is_some() => Vec::new(), (None, Some(id)) => vec![id], - (None, None) => selection_targets(st), + (None, None) => { + let ids = selection_targets(st); + if let Some(bg) = background_among(&st.doc, &ids) { + let name = st.doc.layer(bg).map_or("Background", |l| l.name.as_str()); + return Err(EngineError::Other(format!("layer \"{name}\" is locked"))); + } + ids + } }; if let Some(missing) = ids.iter().find(|id| st.doc.layer(**id).is_none()) { return Err(EngineError::NoLayer(*missing)); @@ -1016,7 +1032,7 @@ pub fn specs() -> Vec { label: "Free Transform", menu: &[], shortcut: None, - params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' 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?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"stepName":"Transform Path"? (History step name; the shape tools' bounding box sends it; default "Free Transform"; any other value is refused),"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?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers, as Photoshop's Free Transform with several selected; refused as locked when the Background is among several),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' 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?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"stepName":"Transform Path"? (History step name; the shape tools' bounding box sends it; default "Free Transform"; any other value is refused),"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)}"##, enabled: has_layer, journal: true, run: transform, diff --git a/crates/engine/src/cmd/transform_cmds/multi_tests.rs b/crates/engine/src/cmd/transform_cmds/multi_tests.rs index 329775e7a3..a50e4239fd 100644 --- a/crates/engine/src/cmd/transform_cmds/multi_tests.rs +++ b/crates/engine/src/cmd/transform_cmds/multi_tests.rs @@ -91,13 +91,17 @@ fn the_layers_param_names_the_layers_and_a_listed_group_carries_its_children() { } #[test] -fn the_background_stays_out_of_a_selection_of_several() { +fn the_background_among_a_selection_of_several_refuses_as_locked() { + // Photoshop shows its locked-layer alert rather than leaving the Background out (#11101). let (mut s, paint, text) = two_layers(); let bg = s.active().unwrap().doc.layers[0].id; s.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); - let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap(); - assert_eq!(r["layers"], json!([paint.0, text.0])); let st = s.active().unwrap(); + let (before, revision) = (st.doc.clone(), st.revision); + let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap_err().to_string(); + assert!(err.contains("\"Background\" is locked"), "{err}"); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision, "nothing moved, not even {paint:?} or {text:?}"); assert!(st.doc.is_background(bg), "still the Background"); assert_eq!(st.doc.layer(bg).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 200, 200)); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 656025addb..7a7ece561f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -118,6 +118,7 @@ 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}; +pub(crate) use transform_controls::ControlsPress; 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_turn}; #[cfg(test)] @@ -1036,12 +1037,13 @@ fn handle_canvas_tool_stroke( if buttons.started && let Some(p) = ui.input(|i| i.pointer.press_origin()).filter(|p| rect.contains(*p)).or(response.interact_pointer_pos()) { - if tool == Tool::Move && app.ui.transform.is_none() { - begin_transform_controls_at(app, ctx, xf, p); + // A press the controls refused (the Background among the layers) goes nowhere else (#11101). + let refused = tool == Tool::Move && app.ui.transform.is_none() && begin_transform_controls_at(app, ctx, xf, p) == ControlsPress::Refused; + if !refused { + let d = xf.to_doc(p); + let press_mods = if capture_press { ctx.data(|d| d.get_temp(tool_press)).unwrap_or(mods) } else { mods }; + tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods); } - let d = xf.to_doc(p); - let press_mods = if capture_press { ctx.data(|d| d.get_temp(tool_press)).unwrap_or(mods) } else { mods }; - tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods); } // A stroke started on the press takes its moves from the press on, not only once egui // recognises a drag (past its click distance): otherwise the first few points are lost @@ -1200,10 +1202,10 @@ fn handle_canvas_tool_clicks( app.ui.status = e; } } + // A click the transform controls refused (the Background among the layers) goes + // nowhere else (#11101). + _ if tool == Tool::Move && app.ui.transform.is_none() && begin_transform_controls_at(app, ctx, xf, p) == ControlsPress::Refused => {} _ => { - if tool == Tool::Move && app.ui.transform.is_none() { - begin_transform_controls_at(app, ctx, xf, p); - } // If this tool was not handled on pointer-down, fall back to a // click-sized gesture while preserving pen pressure (mouse stays at 1). tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, click_mods); diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index c63d1d1c76..97325bdb40 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -22,7 +22,9 @@ pub(crate) fn layer_edges_rect(app: &PhotocraftApp, idx: usize) -> Option