Files
photocraft/crates/engine/src/new_layer.rs
T
Tanish MankuandGitHub fdaa9b8316 Free Transform: New Layer applies the transform first; tests for switching tool, layer or New Layer (#5665, #5667) (#5859)
* Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667)

Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment.

* New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order

* Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal

* Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied

* Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute

* Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines)
2026-10-15 15:26:13 -07:00

238 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Layer › New › Layer: Photoshop's New Layer options (name, Use Previous Layer to Create Clipping
//! Mask, color label, mode, opacity, and Fill with neutral color), applied in one history step.
use photocraft_doc::{BlendMode, LabelColor, Layer};
use serde_json::{Value, json};
use crate::{EngineError, Result, Session};
const CMD: &str = "layer.new.layer";
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
}
/// The color that leaves everything below unchanged in `mode` (Photoshop's "Fill with
/// Multiply-neutral color (white)"), as RGB, and its name. `None` for modes that have none.
pub fn neutral_color(mode: BlendMode) -> Option<([f32; 3], &'static str)> {
use BlendMode::*;
match mode {
Darken | Multiply | ColorBurn | LinearBurn | DarkerColor | Divide => Some(([1.0; 3], "white")),
Lighten | Screen | ColorDodge | LinearDodge | LighterColor | Difference | Exclusion | Subtract => Some(([0.0; 3], "black")),
Overlay | SoftLight | HardLight | VividLight | LinearLight | PinLight => Some(([0.5; 3], "50% gray")),
_ => None,
}
}
/// A New Layer call's params, checked.
pub(crate) struct NewLayer {
clip: bool,
mode: BlendMode,
opacity: f32,
label: LabelColor,
neutral: Option<[f32; 3]>,
below: bool,
name: Option<String>,
}
/// `layer.new.layer`: a pixel layer above the active one (below it with `position: below`, the
/// Layers panel's ⌘/Ctrl-click), joining the clip group it lands in.
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let NewLayer { clip, mode, opacity, label, neutral, below, name } = parse(p)?;
let id = s.edit("New Layer", |doc, active| {
let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer"));
let mut layer = Layer::raster(name, doc.pixel_format());
layer.blend = mode;
layer.opacity = opacity;
layer.label = label;
if let Some(rgb) = neutral {
let bounds = doc.bounds();
let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?;
let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]);
surface.fill_rect(bounds, &px);
}
// Nothing may sit under the Background: below it the layer lands above it instead.
let id = match *active {
Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer),
_ => doc.insert_above(*active, layer),
};
crate::clip_group::settle(doc, &[id], &Default::default(), clip);
*active = Some(id);
Ok(id)
})?;
crate::layer_multi_cmds::note_inert_insert(s, id);
Ok(json!({ "layer": id.0 }))
}
/// Check a New Layer call's params without running it (Free Transform applies its box before a
/// new layer only when the new layer can be made).
pub(crate) fn parse(p: &Value) -> Result<NewLayer> {
let flag = |k: &str| match p.get(k) {
None | Some(Value::Null) => Ok(false),
Some(v) => v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))),
};
let clip = flag("clipToPrevious")?;
let fill_neutral = flag("fillNeutral")?;
let mode = match p.get("mode") {
None | Some(Value::Null) => BlendMode::Normal,
Some(v) => {
let name = v.as_str().ok_or_else(|| bad("`mode` must be a blend mode name"))?;
crate::commands::blend_from_str(name).filter(|m| *m != BlendMode::PassThrough).ok_or_else(|| bad(format!("unknown layer blend mode `{name}`")))?
}
};
let opacity = match p.get("opacity") {
None | Some(Value::Null) => 1.0,
Some(v) => v.as_f64().filter(|o| o.is_finite()).ok_or_else(|| bad("`opacity` must be a number from 0 to 1"))?.clamp(0.0, 1.0) as f32,
};
let label = match p.get("color") {
None | Some(Value::Null) => LabelColor::None,
Some(v) => v.as_str().and_then(LabelColor::from_id).ok_or_else(|| bad(format!("`color` must be a layer color id, not {v}")))?,
};
let neutral =
if fill_neutral { Some(neutral_color(mode).ok_or_else(|| bad(format!("{} has no neutral color to fill with", mode.label())))?.0) } else { None };
let below = match p.get("position") {
None | Some(Value::Null) => false,
Some(v) => match v.as_str() {
Some("above") => false,
Some("below") => true,
_ => return Err(bad("`position` must be above or below")),
},
};
let name = match p.get("name") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()),
};
Ok(NewLayer { clip, mode, opacity, label, neutral, below, name })
}
#[cfg(test)]
mod tests {
use photocraft_doc::{BlendMode, LabelColor};
use photocraft_geom::Rect;
use serde_json::json;
use super::neutral_color;
use crate::Session;
/// A document with a busy Background: every pixel a different colour.
fn busy(mode: &str, depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "mode": mode, "depth": depth})).unwrap();
s.edit("busy", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let fmt = surf.format();
for y in 0..32 {
for x in 0..32 {
let rgba = [x as f32 / 31.0, y as f32 / 31.0, ((x * 7 + y * 3) % 32) as f32 / 31.0, 1.0];
surf.fill_rect(Rect::new(x, y, x + 1, y + 1), &photocraft_raster::from_rgba(&fmt, rgba));
}
}
Ok(())
})
.unwrap();
s
}
fn max_diff(a: &photocraft_compose::Buffer, b: &photocraft_compose::Buffer) -> f32 {
a.px.iter().zip(&b.px).flat_map(|(p, q)| p.iter().zip(q).map(|(x, y)| (x - y).abs())).fold(0.0, f32::max)
}
/// Fill with neutral color leaves the composite unchanged in every mode that offers it, at
/// every depth; the wrong neutral (gray for Multiply) does change it.
#[test]
fn neutral_fills_leave_the_image_unchanged() {
for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("gray", 8), ("gray", 16)] {
let s0 = busy(mode, depth);
let doc0 = s0.active().unwrap().doc.clone();
let shown = photocraft_compose::render(&doc0, doc0.bounds());
for m in BlendMode::LAYER_MODES.into_iter().filter(|m| neutral_color(*m).is_some()) {
let mut s = busy(mode, depth);
s.execute("layer.new.layer", json!({"mode": m.label(), "fillNeutral": true})).unwrap();
let doc = &s.active().unwrap().doc;
let top = doc.layers.last().unwrap();
assert_eq!(top.blend, m);
assert!(top.surface().unwrap().tile_count() > 0, "{m:?} filled");
let d = max_diff(&photocraft_compose::render(doc, doc.bounds()), &shown);
// 8-bit stores 50% gray as 128 (0.502), which can move a pixel by one level.
let tolerance = if depth == 8 { 1.0 / 255.0 + 1e-6 } else { 1e-3 };
assert!(d <= tolerance, "{mode}/{depth}: {m:?} changed the image by {d}");
}
let mut s = busy(mode, depth);
s.execute("layer.new.layer", json!({"mode": "Multiply"})).unwrap();
s.execute("edit.fill", json!({"contents": "gray"})).unwrap();
let doc = &s.active().unwrap().doc;
assert!(max_diff(&photocraft_compose::render(doc, doc.bounds()), &shown) > 0.1, "{mode}/{depth}: the control changes it");
}
for m in [BlendMode::Normal, BlendMode::Dissolve, BlendMode::HardMix, BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity] {
assert!(neutral_color(m).is_none(), "{m:?}");
}
}
#[test]
fn options_apply_in_one_step() {
let mut s = busy("rgb", 8);
let steps = s.active().unwrap().history.past_len();
let id =
s.execute("layer.new.layer", json!({"name": "Shade", "mode": "Overlay", "opacity": 0.4, "color": "blue", "fillNeutral": true})).unwrap()["layer"]
.as_u64()
.unwrap();
let st = s.active().unwrap();
assert_eq!(st.history.past_len(), steps + 1);
let l = st.doc.layer(photocraft_doc::LayerId(id)).unwrap();
assert_eq!((l.name.as_str(), l.blend, l.opacity, l.label), ("Shade", BlendMode::Overlay, 0.4, LabelColor::Blue));
assert_eq!(st.active_layer, Some(l.id));
// Defaults: what the Layers panel button makes.
s.execute("layer.new.layer", json!({})).unwrap();
let st = s.active().unwrap();
let l = st.doc.layers.last().unwrap();
assert_eq!((l.blend, l.opacity, l.label, l.clipped), (BlendMode::Normal, 1.0, LabelColor::None, false));
assert_eq!(l.surface().unwrap().tile_count(), 0);
}
/// `position: below` (⌘/Ctrl-click on the panel button) lands under the active layer; the
/// Background stays at the bottom, so below it the layer lands above it.
#[test]
fn position_below_lands_under_the_active_layer() {
let mut s = busy("rgb", 8);
let names = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
s.execute("layer.new.layer", json!({"name": "A"})).unwrap();
let below = s.execute("layer.new.layer", json!({"name": "Below", "position": "below"})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(names(&s), ["Background", "Below", "A"]);
assert_eq!(s.active().unwrap().active_layer, Some(photocraft_doc::LayerId(below)));
s.execute("layer.new.layer", json!({"name": "Above", "position": "above"})).unwrap();
assert_eq!(names(&s), ["Background", "Below", "Above", "A"]);
let bg = s.active().unwrap().doc.layers[0].id.0;
s.execute("layer.select", json!({"layer": bg})).unwrap();
s.execute("layer.new.layer", json!({"name": "OverBg", "position": "below"})).unwrap();
assert_eq!(names(&s), ["Background", "OverBg", "Below", "Above", "A"]);
}
/// Bad params are errors, never panics, and change nothing.
#[test]
fn bad_params_are_errors() {
let mut s = busy("rgb", 8);
let n = s.active().unwrap().doc.layers.len();
for p in [
json!({"mode": "Pass Through"}),
json!({"mode": "Sparkle"}),
json!({"mode": 3}),
json!({"opacity": "half"}),
json!({"color": "plaid"}),
json!({"color": 1}),
json!({"fillNeutral": true}),
json!({"fillNeutral": true, "mode": "Hue"}),
json!({"fillNeutral": "yes"}),
json!({"clipToPrevious": 1}),
json!({"name": 7}),
json!({"position": "sideways"}),
json!({"position": 1}),
] {
assert!(s.execute("layer.new.layer", p.clone()).is_err(), "{p}");
}
assert_eq!(s.active().unwrap().doc.layers.len(), n);
// Out-of-range opacity is clamped, as layer.setProps does.
s.execute("layer.new.layer", json!({"opacity": 7})).unwrap();
assert_eq!(s.active().unwrap().doc.layers.last().unwrap().opacity, 1.0);
}
}