Move tool on a locked layer: Photoshop's message (#2249)

* Move tool on a locked layer: Photoshop's message

Dragging with the Move tool (or Cmd with another tool) on a layer it can't
move showed nothing: the Background or a position-locked layer with no
selection, or a locked layer whose selected pixels can't float. Photoshop
puts up "Could not use the move tool because the layer is locked." when
the drag starts (a plain click says nothing); move_lock.rs does the same
with the Error dialog, translated in every catalogue. The float lock rule
moves to float_cmds::locked_for_float so the UI can ask before the press
goes anywhere.

Also in Dutch and Ukrainian. The Cmd-with-a-painting-tool case lands with
the Cmd-as-Move change, whichever merges second.

* Fix merge: close the lock-message test

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
texjer 2026-10-10 03:39:40 -04:00 committed by GitHub
parent 1f84f19caf
commit 6a39da98c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 197 additions and 11 deletions

View File

@ -130,14 +130,19 @@ fn can_float(s: &Session) -> std::result::Result<(), String> {
if !matches!(l.content, LayerContent::Raster(_)) {
return Err("the active layer has no pixels to move".into());
}
let locks = st.doc.effective_locks(l.id);
// The Background is position-locked, but its selected pixels move (Photoshop).
if locks.all || (locks.position && !crate::extra_cmds::is_background(l)) {
if locked_for_float(&st.doc, l) {
return Err(format!("layer \"{}\" is locked", l.name));
}
Ok(())
}
/// Do `l`'s locks (its own, or a locked group's: `effective_locks`) stop its selected pixels
/// floating? The Background is position-locked, but its selected pixels move (Photoshop).
pub fn locked_for_float(doc: &Document, l: &photocraft_doc::Layer) -> bool {
let locks = doc.effective_locks(l.id);
locks.all || (locks.position && !crate::extra_cmds::is_background(l))
}
fn float(s: &mut Session, p: &Value) -> Result<Value> {
let (dx, dy) = (int_param(p, "dx"), int_param(p, "dy"));
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);

View File

@ -3690,6 +3690,25 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
ToolEvent::Up { .. } => return,
}
}
// A locked layer (the Background without a selection, say): no drag, and Photoshop's message
// once the pointer moves (a click says nothing). ⌘ with a selection tool here (#896); the Move
// tool below, once Auto-Select has picked the layer.
match ev {
ToolEvent::Down { x, y, .. } if tool != Tool::Move && app.ui.transform.is_none() && crate::move_lock::blocked(app, tool, [x, y], mods) => {
app.move_blocked = true;
return;
}
ToolEvent::Move { .. } if app.move_blocked => {
app.move_blocked = false;
crate::move_lock::prompt(app);
return;
}
ToolEvent::Up { .. } if app.move_blocked => {
app.move_blocked = false;
return;
}
_ => {}
}
// Move tool over a guide drags the guide (off the canvas deletes it).
match ev {
ToolEvent::Down { x, y, pressure } if tool == Tool::Move => {
@ -3697,8 +3716,20 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
app.guide_drag = Some(crate::rulers::GuideDrag { vertical, index: Some(i), pos: if vertical { x } else { y } });
return;
}
// Auto-Select (or ⌘-click while it is off) picks the layer under the pointer first
// (not when the selected pixels move: those are the active layer's).
if !crate::move_ui::moves_selected_pixels(app) && app.ui.tool_options.move_auto_select != mods.command {
let target = app.ui.tool_options.move_target.clone();
let mode = if mods.shift { "add" } else { "replace" };
let _ = app.run("layer.pickAt", json!({"x": x, "y": y, "target": target, "mode": mode}));
}
// A locked layer: no drag, and Photoshop's message once the pointer moves (`move_lock`).
if crate::move_lock::blocked(app, tool, [x, y], mods) {
app.move_blocked = true;
return;
}
// With a selection: cut the selected pixels (⌥ copies them) and drag them as a floating
// piece, from anywhere, as a marquee ⌘-drag does (no Auto-Select pick).
// piece, from anywhere, as a marquee ⌘-drag does.
if crate::move_ui::moves_selected_pixels(app) {
if crate::move_ui::float_selected(app, mods.alt, 0.0, 0.0) {
let mut d = Drag::new(tool, [x, y], vec![[x, y, pressure as f64]], mods, false);
@ -3707,12 +3738,6 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
return;
}
// Auto-Select (or ⌘-click while it is off) picks the layer under the pointer first.
if app.ui.tool_options.move_auto_select != mods.command {
let target = app.ui.tool_options.move_target.clone();
let mode = if mods.shift { "add" } else { "replace" };
let _ = app.run("layer.pickAt", json!({"x": x, "y": y, "target": target, "mode": mode}));
}
}
ToolEvent::Move { x, y, .. } => {
if let Some(d) = app.guide_drag.as_mut().filter(|d| d.index.is_some()) {
@ -3953,7 +3978,7 @@ pub fn selection_drag_kind(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: e
/// Does a ⌘ (⌘⌥) press with selection tool `tool` at `p` move the whole layer (a duplicate with
/// ⌥), as the Move tool would? Outside the selection or without one; inside it, ⌘ drags the
/// selected pixels instead (`selection_drag_kind`).
fn command_moves_layer(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: egui::Modifiers) -> bool {
pub(crate) fn command_moves_layer(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: egui::Modifiers) -> bool {
let selection_tool = matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::Lasso | Tool::PolygonLasso | Tool::MagicWand);
let floating = app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some());
selection_tool && mods.command && !mods.shift && !floating && app.ui.polygon.is_empty() && !inside_selection(app, p)

View File

@ -1636,6 +1636,7 @@
Millimeters Milimetry
Mobile Mobilní zařízení
Move Tool Nástroj přesun
Could not use the move tool because the layer is locked. Nástroj Přesun nelze použít, protože vrstva je zamčená.
Multiply Násobit
My Brushes Moje štětce
Neutrals Neutrální

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

View File

@ -1616,6 +1616,7 @@
Millimeters Millimeter
Mobile Mobil
Move Tool Verschieben-Werkzeug
Could not use the move tool because the layer is locked. Das Verschieben-Werkzeug konnte nicht verwendet werden, weil die Ebene gesperrt ist.
Multiply Multiplizieren
My Brushes Meine Pinsel
Neutrals Neutrale

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

View File

@ -3215,6 +3215,7 @@ cameraRaw Yellow Κίτρινο
Magic Eraser Tool Μαγική γόμα (Magic Eraser Tool)
Magic Wand Tool Μαγικό ραβδί (Magic Wand Tool)
Move Tool Μετακίνηση (Move Tool)
Could not use the move tool because the layer is locked. Δεν ήταν δυνατή η χρήση του εργαλείου Μετακίνηση, επειδή το επίπεδο είναι κλειδωμένο.
Note Tool Σημείωση (Note Tool)
Object Selection Tool Επιλογή αντικειμένου (Object Selection Tool)
Paint Bucket Tool Κουβάς γεμίσματος (Paint Bucket Tool)

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

View File

@ -1620,6 +1620,7 @@
Millimeters Milímetros
Mobile Móvil
Move Tool Herramienta Mover
Could not use the move tool because the layer is locked. No se pudo usar la herramienta Mover porque la capa está bloqueada.
Multiply Multiplicar
My Brushes Mis pinceles
Neutrals Neutros

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

View File

@ -1599,6 +1599,7 @@
Millimeters Millimètres
Mobile Mobile
Move Tool Outil Déplacement
Could not use the move tool because the layer is locked. Impossible d'utiliser l'outil Déplacement, car le calque est verrouillé.
Multiply Produit
My Brushes Mes pinceaux
Neutrals Neutres

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

View File

@ -1593,6 +1593,7 @@
Millimeters Milimeter
Mobile Seluler
Move Tool Alat Pindah
Could not use the move tool because the layer is locked. Tidak dapat menggunakan alat Pindahkan karena layer terkunci.
Multiply Multiply
My Brushes Kuas Saya
Neutrals Netral

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

View File

@ -1618,6 +1618,7 @@
Millimeters Millimetri
Mobile Mobile
Move Tool Strumento Sposta
Could not use the move tool because the layer is locked. Impossibile usare lo strumento Sposta perché il livello è bloccato.
Multiply Moltiplica
My Brushes I miei pennelli
Neutrals Neutri

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

View File

@ -1625,6 +1625,7 @@
Millimeters ミリメートル
Mobile モバイル
Move Tool 移動ツール
Could not use the move tool because the layer is locked. レイヤーがロックされているため、移動ツールを使用できません。
Multiply 乗算
My Brushes マイブラシ
Neutrals 中間色系

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

View File

@ -1595,6 +1595,7 @@
Millimeters 밀리미터
Mobile 모바일
Move Tool 이동 도구
Could not use the move tool because the layer is locked. 레이어가 잠겨 있어 이동 도구를 사용할 수 없습니다.
Multiply 곱하기
My Brushes 내 브러시
Neutrals 중립색

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

View File

@ -1608,6 +1608,7 @@
Millimeters Millimeter
Mobile Mobiel
Move Tool Verplaatsgereedschap
Could not use the move tool because the layer is locked. Kan het verplaatsgereedschap niet gebruiken omdat de laag is vergrendeld.
Multiply Vermenigvuldigen
My Brushes Mijn penselen
Neutrals Neutrale tinten

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

View File

@ -1618,6 +1618,7 @@
Millimeters Milimetry
Mobile Urządzenia mobilne
Move Tool Narzędzie Przesunięcie
Could not use the move tool because the layer is locked. Nie można użyć narzędzia Przesunięcie, ponieważ warstwa jest zablokowana.
Multiply Mnożenie
My Brushes Moje pędzle
Neutrals Neutralne

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

View File

@ -1598,6 +1598,7 @@
Millimeters Milímetros
Mobile Dispositivos móveis
Move Tool Ferramenta Mover
Could not use the move tool because the layer is locked. Não foi possível usar a ferramenta Mover porque a camada está bloqueada.
Multiply Multiplicação
My Brushes Meus pincéis
Neutrals Neutros

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

View File

@ -1500,6 +1500,7 @@
Adjustment Layer Корректирующий слой
{name} Properties Свойства: {name}
Move Tool Перемещение
Could not use the move tool because the layer is locked. Не удалось использовать инструмент «Перемещение», потому что слой заблокирован.
Rectangular Marquee Tool Прямоугольная область
Elliptical Marquee Tool Овальная область
Brush Tool Кисть

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

View File

@ -1485,6 +1485,7 @@
Adjustment Layer Коригувальний шар
{name} Properties Властивості: {name}
Move Tool Інструмент «Переміщення»
Could not use the move tool because the layer is locked. Не вдалося використати інструмент «Переміщення», бо шар заблоковано.
Rectangular Marquee Tool Інструмент «Прямокутне виділення»
Elliptical Marquee Tool Інструмент «Еліптичне виділення»
Brush Tool Інструмент «Пензель»

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

View File

@ -1633,6 +1633,7 @@
Millimeters 毫米
Mobile 移动设备
Move Tool 移动工具
Could not use the move tool because the layer is locked. 无法使用移动工具,因为图层已锁定。
Multiply 正片叠底
My Brushes 我的画笔
Neutrals 中性色

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

View File

@ -1626,6 +1626,7 @@
Millimeters 公釐
Mobile 行動裝置
Move Tool 移動工具
Could not use the move tool because the layer is locked. 無法使用移動工具,因為圖層已鎖定。
Multiply 色彩增值
My Brushes 我的筆刷
Neutrals 中間色

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

View File

@ -98,6 +98,7 @@ pub mod menu_catalog;
pub mod menu_nav;
pub mod menus;
pub mod monitor_status;
pub mod move_lock;
pub mod move_mods;
pub mod move_ui;
pub mod native_menu;
@ -371,6 +372,9 @@ pub struct PhotocraftApp {
monitors: monitor_status::State,
checker: Option<egui::TextureHandle>,
drag: Option<canvas::Drag>,
/// A Move-tool press landed on a locked layer: the first pointer move shows Photoshop's
/// message (`move_lock`), a plain click shows nothing.
pub(crate) move_blocked: bool,
/// The tool pointer events go to this frame when it isn't the selected one: the Move tool
/// while ⌘ is held (`hold_keys::cmd_moves`). Set by the canvas for its gestures, never saved.
pub(crate) tool_override: Option<state::Tool>,
@ -571,6 +575,7 @@ impl PhotocraftApp {
monitors: Default::default(),
checker: None,
drag: None,
move_blocked: false,
tool_override: None,
live_stroke: None,
trail: None,

View File

@ -409,6 +409,93 @@ fn alt_with_nothing_selected_draws_a_new_selection() {
assert!(after.is_none_or(|r| r.is_empty() || r != before), "⌥ subtracted: {before:?} -> {after:?}");
}
fn error_dialogs(app: &PhotocraftApp) -> Vec<String> {
app.ui.dialogs.iter().filter(|d| d.kind == crate::state::DialogKind::Error).filter_map(|d| d.fields.get("message")?.as_str().map(String::from)).collect()
}
/// Photoshop's "Could not use the move tool because the layer is locked." when a Move-tool drag
/// (the Move tool, or ⌘ with a marquee) starts on a layer it can't move: the Background with no
/// selection, a position-locked layer; a plain click says nothing, and the drag never starts.
#[test]
fn move_on_the_background_without_a_selection_shows_photoshops_lock_message() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
app.sync_views();
app.ui.tool = Tool::Move;
app.ui.tool_options.move_auto_select = false;
let steps = app.session.active().unwrap().history.past_len();
use crate::canvas::{ToolEvent, tool_event};
// A click: no message.
tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, Modifiers::NONE);
tool_event(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, Modifiers::NONE);
assert!(error_dialogs(&app).is_empty() && !app.move_blocked);
// A drag: the message once, nothing moved, no drag in progress.
tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, Modifiers::NONE);
tool_event(&mut app, ToolEvent::Move { x: 30.0, y: 20.0, pressure: 1.0 }, Modifiers::NONE);
tool_event(&mut app, ToolEvent::Move { x: 40.0, y: 20.0, pressure: 1.0 }, Modifiers::NONE);
tool_event(&mut app, ToolEvent::Up { x: 40.0, y: 20.0 }, Modifiers::NONE);
assert_eq!(error_dialogs(&app), [crate::move_lock::MESSAGE]);
assert!(app.drag.is_none(), "no drag started");
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
app.ui.dialogs.clear();
// ⌘-drag with a marquee (the Move tool for the drag, #896): the same.
app.ui.tool = Tool::RectMarquee;
drag(&mut app, [20.0, 20.0], [40.0, 20.0], Modifiers::COMMAND);
assert_eq!(error_dialogs(&app), [crate::move_lock::MESSAGE]);
app.ui.dialogs.clear();
// A position-locked layer: the same; unlocked, it moves.
app.run("layer.new.layer", json!({})).unwrap();
app.session
.edit("lock", |doc, a| {
doc.layer_mut(a.unwrap()).unwrap().locks.position = true;
Ok(())
})
.unwrap();
drag(&mut app, [20.0, 20.0], [40.0, 20.0], Modifiers::COMMAND);
assert_eq!(error_dialogs(&app).len(), 1);
app.ui.dialogs.clear();
app.session
.edit("unlock", |doc, a| {
doc.layer_mut(a.unwrap()).unwrap().locks.position = false;
Ok(())
})
.unwrap();
drag(&mut app, [20.0, 20.0], [40.0, 20.0], Modifiers::COMMAND);
assert!(error_dialogs(&app).is_empty());
}
/// With a selection the Background's pixels do move (its lock is partial: transparency and
/// position), so no message; a layer locked all over gets it, with the marquee's ⌘-drag inside the
/// selection as with the Move tool.
#[test]
fn selected_pixels_move_off_the_background_but_not_off_a_fully_locked_layer() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
app.sync_views();
app.ui.extras.snap = false;
app.ui.view.show.smart_guides = false;
app.ui.tool = Tool::RectMarquee;
app.run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
drag(&mut app, [20.0, 20.0], [50.0, 20.0], Modifiers::COMMAND);
assert!(error_dialogs(&app).is_empty());
assert_eq!(photocraft_engine::float_cmds::floating(app.session.active().unwrap()).map(|f| f.offset), Some((30, 0)));
let (mut app, layer) = painted();
app.session
.edit("lock", |doc, _| {
doc.layer_mut(layer).unwrap().locks.all = true;
Ok(())
})
.unwrap();
// ⌘ with the Brush is the Move tool too (#2248).
for (tool, m) in [(Tool::RectMarquee, Modifiers::COMMAND), (Tool::Move, Modifiers::NONE), (Tool::Brush, Modifiers::COMMAND)] {
app.ui.tool = tool;
drag(&mut app, [20.0, 20.0], [40.0, 20.0], m);
assert_eq!(error_dialogs(&app), [crate::move_lock::MESSAGE], "{tool:?}");
assert!(photocraft_engine::float_cmds::floating(app.session.active().unwrap()).is_none(), "{tool:?}");
app.ui.dialogs.clear();
}
}
/// Photoshop's cursor over a selection: the move cursor where a marquee press moves the outline,
/// scissors where ⌘ (or the Move tool) cuts the selected pixels, a double arrow where ⌥ copies
/// them, a hollow arrowhead while they are dragged, and a plain arrow over the floating piece.

View File

@ -0,0 +1,48 @@
//! Photoshop's "Could not use the move tool because the layer is locked." A Move-tool press (the
//! Move tool itself, or ⌘ with a selection tool, `canvas::command_moves_layer`) on a layer it
//! can't move opens that message in a dialog instead
//! of silently doing nothing: the Background and any position-locked layer when there is no
//! selection, and a layer whose selected pixels can't float
//! (`photocraft_engine::float_cmds::locked_for_float`) when there is one. As in Photoshop the
//! message comes when the pointer starts to drag; a plain click says nothing. OK closes it.
use serde_json::json;
use crate::PhotocraftApp;
use crate::state::{DialogKind, Tool};
pub const MESSAGE: &str = "Could not use the move tool because the layer is locked.";
/// Would a press at document point `p` with `tool` in effect and `mods` held be a move that
/// fails because of a lock?
pub fn blocked(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: egui::Modifiers) -> bool {
let Some(st) = app.session.active() else { return false };
let floating = photocraft_engine::float_cmds::floating(st).is_some();
// The selected pixels: only a fresh cut can be refused.
let pixels_locked =
|| !floating && st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| photocraft_engine::float_cmds::locked_for_float(&st.doc, l));
// The layers: any of them locked in place refuses the whole move.
let layers_locked = || {
photocraft_engine::layer_multi_cmds::move_targets(&st.doc, &st.selected_layers()).iter().any(|id| {
let locks = st.doc.effective_locks(*id);
locks.position || locks.all
})
};
if tool == Tool::Move {
// `moves_selected_pixels` is false for a layer whose pixels can't float: then the Move tool
// moves the layer, which its position lock refuses too.
if crate::move_ui::moves_selected_pixels(app) {
return pixels_locked();
}
return !floating && st.doc.selection.is_some() && pixels_locked() || layers_locked();
}
if crate::canvas::command_moves_layer(app, tool, p, mods) {
return layers_locked();
}
crate::canvas::selection_drag_kind(app, tool, p, mods) == Some(true) && mods.command && pixels_locked()
}
/// Opens the message. The dialog is ordinary UI state (`ui.dialog.confirm` closes it).
pub fn prompt(app: &mut PhotocraftApp) {
app.ui.open_dialog(DialogKind::Error, json!({"message": tl!(MESSAGE)}).as_object().cloned().unwrap_or_default());
}