Merge PR #6352: fix(engine): honor lock all on layer mask commands

This commit is contained in:
David Mann
2026-10-22 23:40:15 +02:00
6 changed files with 106 additions and 2 deletions
+3 -1
View File
@@ -80,7 +80,7 @@ pub(crate) fn paint_surface<'a>(doc: &'a mut Document, id: LayerId, p: &Value) -
}
}
fn check_all_unlocked(doc: &Document, id: LayerId) -> Result<()> {
pub(crate) fn check_all_unlocked(doc: &Document, id: LayerId) -> Result<()> {
let locks = doc.effective_locks(id);
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
if locks.all {
@@ -461,6 +461,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value)
// The targeted layer mask (#780): ⌘I inverts it, as in Photoshop.
let id = layer_param(s, &Value::Null)?;
return s.edit(label, |doc, _| {
check_all_unlocked(doc, id)?;
let sel = doc.selection.clone();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let mask = l.mask.as_mut().ok_or_else(|| EngineError::Other("layer has no mask".into()))?;
@@ -1694,6 +1695,7 @@ fn set_mask_with(s: &mut Session, p: &Value, label: &str, mask: Option<LayerMask
s.edit(label, |doc, _| {
let mask = mask.clone().map(|mask| mask_at_depth(mask, doc.depth)).transpose()?;
for id in &ids {
check_all_unlocked(doc, *id)?;
if mask.is_some() {
crate::extra_cmds::background_to_layer_for_mask(doc, *id);
}
+7
View File
@@ -366,6 +366,13 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
params["target"] = p.get("target").cloned().unwrap_or_default();
return crate::edit_cmds::paste_to_target(s, &params, false, Some(&limit), label);
}
// The pasted layer lands beside the active one, inside its group, and a Lock-All group refuses
// its layer mask: refuse before pasting rather than leave an unmasked paste behind.
let d = s.active().ok_or(EngineError::NoDocument)?;
let group = d.active_layer.and_then(|a| d.doc.path_of(a)).and_then(|p| d.doc.layer_at(p.split_last()?.1).map(|g| g.id));
if let Some(g) = group {
crate::commands::check_all_unlocked(&d.doc, g)?;
}
let key = step_key(s, "pasteInto");
params["coalesce"] = json!(key);
params["target"] = json!("pixels");
+2
View File
@@ -204,6 +204,7 @@ fn hide_selection(s: &mut Session, p: &Value) -> Result<Value> {
mask.surface.write_region(bounds, &v);
mask.surface.prune();
for id in ids {
crate::commands::check_all_unlocked(doc, id)?;
crate::extra_cmds::background_to_layer_for_mask(doc, id);
doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(mask.clone());
}
@@ -219,6 +220,7 @@ fn mask_all_objects(s: &mut Session, p: &Value) -> Result<Value> {
let id = layer_param(s, p)?;
let d = s.active().ok_or(EngineError::NoDocument)?;
let doc = d.doc.clone();
crate::commands::check_all_unlocked(&doc, id)?;
let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let region: Option<Region> = match layer.surface() {
Some(surf) if matches!(layer.content, LayerContent::Raster(_)) => {
+1
View File
@@ -44,6 +44,7 @@ mod layer_mask_depth;
mod layer_props_several;
mod layer_range_panel_order;
mod live_line_transform;
mod mask_lock_all;
mod merge_transparent_bounds;
mod missing_layer_error;
mod nonfinite_style;
+92
View File
@@ -0,0 +1,92 @@
use photocraft_doc::LayerMask;
use photocraft_engine::Session;
use serde_json::json;
const COMMANDS: [&str; 7] = [
"layer.layerMask.revealAll",
"layer.layerMask.hideAll",
"layer.layerMask.revealSelection",
"layer.layerMask.hideSelection",
"layer.layerMask.delete",
"layer.maskAllObjects",
"image.adjustments.invert",
];
fn masked(all: bool) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap();
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("paint.stroke", json!({"points": [[25, 20], [35, 20]], "size": 6, "hardness": 1.0, "color": "#000000", "target": "mask"})).unwrap();
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 20, "height": 15})).unwrap();
s.execute("layer.setProps", json!({"locks": {"all": all}})).unwrap();
s
}
fn mask(s: &Session) -> Option<LayerMask> {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().mask.clone()
}
fn undo_depth(s: &Session) -> usize {
s.active().unwrap().history.past_len()
}
#[test]
fn lock_all_refuses_every_mask_command() {
for cmd in COMMANDS {
let mut s = masked(true);
let before = mask(&s);
let undo = undo_depth(&s);
let err = s.execute(cmd, json!({"target": "mask"})).unwrap_err();
assert_eq!(err.to_string(), "Could not complete your request because the layer \"Layer 1\" is locked", "{cmd}");
assert_eq!(mask(&s), before, "{cmd} left the mask unchanged");
assert_eq!(undo_depth(&s), undo, "{cmd} added no history step");
}
}
#[test]
fn unlocked_layers_still_take_every_mask_command() {
for cmd in COMMANDS {
let mut s = masked(false);
let before = mask(&s);
s.execute(cmd, json!({"target": "mask"})).unwrap_or_else(|e| panic!("{cmd}: {e}"));
assert_ne!(mask(&s), before, "{cmd} changed the mask");
}
}
#[test]
fn a_locked_layer_among_the_selected_layers_refuses_the_whole_mask() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let first = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("layer.setProps", json!({"locks": {"all": true}})).unwrap();
s.execute("layer.select", json!({"layer": first.0, "mode": "add"})).unwrap();
assert_eq!(s.active().unwrap().selected_layers.len(), 2);
assert!(s.execute("layer.layerMask.hideAll", json!({})).is_err());
assert!(s.active().unwrap().doc.walk().iter().all(|(_, _, l)| l.mask.is_none()), "no layer got a mask");
}
#[test]
fn paste_into_a_lock_all_group_refuses_before_pasting() {
for cmd in ["edit.pasteSpecial.pasteInto", "edit.pasteSpecial.pasteOutside"] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
let child = s.execute("layer.new.layer", json!({"name": "Child"})).unwrap()["layer"].as_u64().unwrap();
s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap();
s.execute("select.all", json!({})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let group = s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"layer": group, "locks": {"all": true}})).unwrap();
s.execute("layer.select", json!({"layer": child, "mode": "replace"})).unwrap();
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 20, "height": 15})).unwrap();
let layers = s.active().unwrap().doc.walk().len();
let undo = undo_depth(&s);
assert!(s.execute(cmd, json!({})).is_err(), "{cmd}");
assert_eq!(s.active().unwrap().doc.walk().len(), layers, "{cmd} pasted no layer");
assert_eq!(undo_depth(&s), undo, "{cmd} added no history step");
}
}
+1 -1
View File
@@ -23,8 +23,8 @@ fn busy() -> photocraft_engine::Session {
s.execute("layer.layerStyle.dropShadow", json!({"layer": a})).unwrap();
// #153: the vector mask thumbnail and both link chains take row width too.
s.execute("layer.vectorMask.revealAll", json!({"layer": a})).unwrap();
s.execute("layer.setProps", json!({"layer": a, "blend": "Multiply", "locks": {"all": true}})).unwrap();
s.execute("layer.layerMask.revealAll", json!({"layer": a})).unwrap();
s.execute("layer.setProps", json!({"layer": a, "blend": "Multiply", "locks": {"all": true}})).unwrap();
let b = s.execute("layer.new.layer", json!({"name": format!("{LONG} b{depth}")})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"layer": b, "clipped": true})).unwrap();
s.execute("layer.select", json!({"layer": a})).unwrap();