Files
photocraft/crates/engine/tests/selection_layer_commands.rs
T
Sir StarchandBrandon Thomas 7b3ded0f8b Layers menu and layer commands follow the selection, not the active layer (#2244) (#2254)
* Layer › Rasterize acts on every selected layer; the Layers menu offers Rasterize Layers (#2243)

Rasterize › Layer / Type / Shape / Fill Content / Smart Object took only the active
layer, so right-clicking inside a multi-layer selection converted one layer. Without a
`layer` param they now rasterize every selected layer they apply to, in one history step.
An explicit `layer` (the "Rasterize?" paint prompt, scripts) still targets exactly one.

Layers panel: a multi-layer selection gets one "Rasterize Layers" item (greyed only when
nothing selected can be rasterized, judged by the whole selection, not the clicked layer).
Edit Contents and Convert to Layers act on one layer, so they are not offered there.
Adobe's documentation: select "the layer or layers you'd like to rasterize".

Tests: engine integration (mixed type/shape/fill/smart selection, one undo step, per-kind
commands, explicit layer, nothing to rasterize, bad targets) and ui-egui (menu entries,
enablement, right-click → Rasterize Layers end to end). The new behaviour tests fail on
the old code.

* Layers menu and layer commands follow the selection, not the active layer (#2244)

Availability of Rasterize, Create/Release Clipping Mask, Add Layer Mask and Paste/Clear Layer
Style was decided by the active layer, so one selection was greyed or not depending on which of
its layers happened to be active (with the Background active, Create Clipping Mask and Add Layer
Mask were greyed although they would have run on the other layers), and Rasterize Layer was
enabled for layers it then refused ("nothing to rasterize"). Several of these commands also only
ever acted on the active layer, whichever layer the menu was opened on.

- Add Layer Mask (Reveal/Hide All, Reveal/Hide Selection), Paste Layer Style and Clear Layer Style
  run on every selected layer that can take them (not the Background, layers that already have a
  mask, layers without a style), in one history step. An explicit `layer`, or a single selected
  layer, behaves as before.
- `enable_rules::selection_targets`: availability of those commands, Create/Release Clipping Mask
  and Rasterize is judged by the selection.
- Rasterize Layer is enabled only for type, shape, fill and Smart Object layers, which is what the
  command converts.
- Items with no single target stay greyed for several layers: Blending Options, Copy Layer Style,
  Quick Export, Export As and the per-layer mask items. A multi-layer menu has one stable mask item.

Tests: engine integration (mask, paste and clear on a selection, errors, unchanged single-layer
paths) and a property test over 20 selections x clipboard state x 7 commands: an enabled item runs,
and for several layers a greyed one would have changed nothing. It fails on the old rules for
["R1", "BG"] with the Background active, the case from the bug report.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 04:18:42 -04:00

154 lines
6.4 KiB
Rust

//! Adding a layer mask, Paste Layer Style and Clear Layer Style act on every selected layer that can
//! take them (one history step), not only on the active one. An explicit `layer`, or a single
//! selected layer, behaves exactly as before.
use photocraft_doc::LayerId;
use photocraft_engine::Session;
use serde_json::{Value, json};
fn id_of(r: Value) -> LayerId {
LayerId(r["layer"].as_u64().unwrap())
}
struct Scene {
s: Session,
bg: LayerId,
r1: LayerId,
/// Already has a layer mask.
r2: LayerId,
/// Has a drop shadow.
fx: LayerId,
/// Has a drop shadow.
fx2: LayerId,
}
fn scene() -> Scene {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
let bg = s.active().unwrap().doc.layers[0].id;
let r1 = id_of(s.execute("layer.new.layer", json!({"name": "R1"})).unwrap());
let r2 = id_of(s.execute("layer.new.layer", json!({"name": "R2"})).unwrap());
s.execute("layer.layerMask.hideAll", json!({"layer": r2.0})).unwrap();
let fx = id_of(s.execute("layer.new.layer", json!({"name": "FX"})).unwrap());
s.execute("layer.layerStyle.dropShadow", json!({"layer": fx.0})).unwrap();
let fx2 = id_of(s.execute("layer.new.layer", json!({"name": "FX2"})).unwrap());
s.execute("layer.layerStyle.dropShadow", json!({"layer": fx2.0})).unwrap();
Scene { s, bg, r1, r2, fx, fx2 }
}
impl Scene {
fn select(&mut self, ids: &[LayerId]) {
self.s.execute("layer.select", json!({"layer": ids[0].0})).unwrap();
for id in &ids[1..] {
self.s.execute("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap();
}
}
fn has_mask(&self, id: LayerId) -> bool {
self.s.active().unwrap().doc.layer(id).unwrap().mask.is_some()
}
fn fx_count(&self, id: LayerId) -> usize {
self.s.active().unwrap().doc.layer(id).unwrap().effects.items.len()
}
fn revision(&self) -> u64 {
self.s.active().unwrap().revision
}
}
#[test]
fn add_layer_mask_goes_to_every_selected_layer_that_can_take_one() {
for cmd in ["layer.layerMask.revealAll", "layer.layerMask.hideAll"] {
let mut sc = scene();
// Background (cannot be masked here), a masked layer (keeps its mask) and two more.
sc.select(&[sc.bg, sc.r1, sc.r2, sc.fx]);
let masked_before = sc.s.active().unwrap().doc.layer(sc.r2).unwrap().mask.clone();
sc.s.execute(cmd, json!({})).unwrap();
assert!(sc.has_mask(sc.r1) && sc.has_mask(sc.fx), "{cmd}: the layers without a mask get one");
assert!(!sc.has_mask(sc.bg), "{cmd}: the Background is skipped");
assert_eq!(sc.s.active().unwrap().doc.layer(sc.r2).unwrap().mask, masked_before, "{cmd}: an existing mask is not replaced");
// One history step brings it all back.
assert!(sc.s.undo());
assert!(!sc.has_mask(sc.r1) && !sc.has_mask(sc.fx));
}
}
#[test]
fn hide_selection_masks_every_selected_layer() {
let mut sc = scene();
sc.select(&[sc.r1, sc.fx]);
sc.s.execute("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 10})).unwrap();
sc.s.execute("layer.layerMask.hideSelection", json!({})).unwrap();
assert!(sc.has_mask(sc.r1) && sc.has_mask(sc.fx));
}
#[test]
fn add_layer_mask_with_nothing_to_mask_is_an_error_not_a_step() {
let mut sc = scene();
sc.s.execute("layer.layerMask.hideAll", json!({"layer": sc.fx.0})).unwrap();
sc.select(&[sc.r2, sc.fx]);
let revision = sc.revision();
let err = sc.s.execute("layer.layerMask.revealAll", json!({})).unwrap_err().to_string();
assert!(err.contains("none of the selected layers"), "{err}");
assert_eq!(sc.revision(), revision);
}
#[test]
fn add_layer_mask_with_a_layer_param_or_one_selected_layer_is_unchanged() {
let mut sc = scene();
sc.select(&[sc.r1, sc.fx]);
sc.s.execute("layer.layerMask.revealAll", json!({"layer": sc.r1.0})).unwrap();
assert!(sc.has_mask(sc.r1) && !sc.has_mask(sc.fx), "an explicit layer is the only target");
// A single layer that already has a mask is still replaced, as before.
sc.select(&[sc.r2]);
sc.s.execute("layer.layerMask.revealAll", json!({})).unwrap();
assert!(sc.has_mask(sc.r2));
}
#[test]
fn paste_layer_style_goes_to_every_selected_layer_but_the_background() {
let mut sc = scene();
sc.s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": sc.fx.0})).unwrap();
sc.select(&[sc.bg, sc.r1, sc.r2]);
sc.s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap();
assert_eq!((sc.fx_count(sc.r1), sc.fx_count(sc.r2), sc.fx_count(sc.bg)), (1, 1, 0));
assert!(sc.s.undo());
assert_eq!((sc.fx_count(sc.r1), sc.fx_count(sc.r2)), (0, 0), "one history step");
// An explicit layer is the only target.
sc.select(&[sc.r1, sc.r2]);
sc.s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": sc.r1.0})).unwrap();
assert_eq!((sc.fx_count(sc.r1), sc.fx_count(sc.r2)), (1, 0));
}
#[test]
fn clear_layer_style_goes_to_every_selected_layer_that_has_one() {
let mut sc = scene();
sc.select(&[sc.fx, sc.fx2, sc.r1]);
sc.s.execute("layer.layerStyle.clear", json!({})).unwrap();
assert_eq!((sc.fx_count(sc.fx), sc.fx_count(sc.fx2)), (0, 0));
assert!(sc.s.undo());
assert_eq!((sc.fx_count(sc.fx), sc.fx_count(sc.fx2)), (1, 1), "one history step");
// Nothing selected has a style: an error and no step.
sc.select(&[sc.r1, sc.r2]);
let revision = sc.revision();
let err = sc.s.execute("layer.layerStyle.clear", json!({})).unwrap_err().to_string();
assert!(err.contains("none of the selected layers"), "{err}");
assert_eq!(sc.revision(), revision);
// One layer, or an explicit layer, as before (clearing a layer without a style is allowed).
sc.select(&[sc.fx, sc.fx2]);
sc.s.execute("layer.layerStyle.clear", json!({"layer": sc.fx.0})).unwrap();
assert_eq!((sc.fx_count(sc.fx), sc.fx_count(sc.fx2)), (0, 1));
sc.select(&[sc.r1]);
sc.s.execute("layer.layerStyle.clear", json!({})).unwrap();
}
#[test]
fn bad_targets_fail_gracefully() {
let mut s = Session::new();
for id in ["layer.layerMask.revealAll", "layer.layerMask.hideAll", "layer.layerStyle.pasteLayerStyle", "layer.layerStyle.clear"] {
assert!(s.execute(id, json!({})).is_err(), "{id}: no document");
}
let mut sc = scene();
for id in ["layer.layerMask.revealAll", "layer.layerStyle.pasteLayerStyle", "layer.layerStyle.clear"] {
assert!(sc.s.execute(id, json!({"layer": 9_999_999})).is_err(), "{id}: no such layer");
}
}