Compare commits

...
Author SHA1 Message Date
Tanish Manku d9e1c10c30 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:22:09 -07:00
Tanish Manku 4a1acca879 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 2026-10-15 15:15:11 -07:00
Tanish Manku a95b3f0a85 Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied 2026-10-15 15:15:11 -07:00
Tanish Manku b7eab1a377 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 2026-10-15 15:15:11 -07:00
Tanish Manku 25a8200110 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 2026-10-15 15:15:10 -07:00
Tanish Manku 6443f5fb4c 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.
2026-10-15 15:15:01 -07:00
7 changed files with 418 additions and 35 deletions

No files matched your search

+1 -11
View File
@@ -671,17 +671,7 @@ impl Session {
return Ok(Started::Done(v));
}
// Pixel commands follow the Channels panel target unless the caller names one.
let run_params = crate::channel_cmds::inject_target(self, id, crate::commands::inject_kind(id, params.clone()));
if let Err(why) = self.explicit_layer_enabled(spec, &params).unwrap_or_else(|| self.precondition(spec, &run_params)) {
return Err(EngineError::Disabled(id.to_string(), why));
}
if let Some(why) = self.job_conflict(id, spec.journal) {
return Err(EngineError::Disabled(id.to_string(), why));
}
// Painting on or moving a hidden layer is refused, as in Photoshop (#571).
if let Some(why) = crate::hidden_target::refusal(self, id, &params) {
return Err(EngineError::Other(why.into()));
}
let run_params = self.admit(id, spec, &params)?;
self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string);
self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params);
self.jobs.spawn = background;
+9 -1
View File
@@ -1487,7 +1487,15 @@ pub fn specs() -> Vec<CommandSpec> {
link_layers(s)
}),
spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)),
spec!("layer.new.groupFromLayers", "Group from Layers…", &["Layer", "New"], None, r##"{"name":str?}"##, has_layer, group_layers),
spec!(
"layer.new.groupFromLayers",
"Group from Layers…",
&["Layer", "New"],
None,
r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##,
has_layer,
group_layers
),
spec!("layer.arrange.reverse", "Reverse", &["Layer", "Arrange"], None, "{}", two_plus, |s, _| reverse(s)),
spec!(
"layer.lockLayers",
+53
View File
@@ -421,6 +421,12 @@ pub struct CopiedStyle {
pub mode: photocraft_color::ColorMode,
}
/// The commands whose [`Session::precheck`] is complete: it accepts exactly what `execute` does
/// (Free Transform applies its box before these, and only when they will run). Layer via Copy and
/// Cut aren't: whether they have anything to copy (a selection over transparent pixels, a group
/// or fill layer) is known only when they run.
pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.new.groupFromLayers", "layer.new.layerFromBackground"];
#[derive(Default)]
pub struct Session {
docs: Vec<DocState>,
@@ -659,6 +665,53 @@ impl Session {
self.live_commit = None;
}
/// Check a call without running it: the command exists, its params have the shape
/// [`Self::execute`] requires (New Layer's fully), and it can run now. For a caller that does
/// something first only when the command will go through: Free Transform applies its box
/// before a new layer is made.
///
/// Complete for the commands in [`PRECHECKED`]: there it accepts exactly what `execute`
/// accepts (a test holds the two together). For others it checks only what they share.
pub fn precheck(&mut self, id: &str, params: &Value) -> Result<()> {
let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if !(params.is_object() || params.is_null()) {
return Err(EngineError::BadParams { cmd: id.to_string(), msg: "params must be a JSON object".into() });
}
crate::param_check::check(spec, params)?;
crate::filters::validate_params(id, params)?;
if id == "layer.new.layer" {
crate::new_layer::parse(params)?;
}
// A command that takes `"layer"` (an id or a name, as it resolves it) fails on one the
// document doesn't have.
if spec.params.contains("\"layer\":id")
&& let Some(l) = commands::explicit_layer(self, params)?
&& self.active().is_none_or(|st| st.doc.layer(l).is_none())
{
return Err(EngineError::BadParams { cmd: id.to_string(), msg: format!("no layer {}", l.0) });
}
self.admit(id, spec, params).map(|_| ())
}
/// What `dispatch` checks once the params are valid and a floating selection has dropped:
/// the precondition (on a named layer for commands that take one), a job running on the
/// document, a hidden target. The run params (the Channels panel's target filled in) when it
/// may run.
pub(crate) fn admit(&mut self, id: &str, spec: &commands::CommandSpec, params: &Value) -> Result<Value> {
let run_params = channel_cmds::inject_target(self, id, commands::inject_kind(id, params.clone()));
if let Err(why) = self.explicit_layer_enabled(spec, params).unwrap_or_else(|| self.precondition(spec, &run_params)) {
return Err(EngineError::Disabled(id.to_string(), why));
}
if let Some(why) = self.job_conflict(id, spec.journal) {
return Err(EngineError::Disabled(id.to_string(), why));
}
// Painting on or moving a hidden layer is refused, as in Photoshop (#571).
if let Some(why) = hidden_target::refusal(self, id, params) {
return Err(EngineError::Other(why.into()));
}
Ok(run_params)
}
/// Is the command currently runnable? (drives menu enablement)
pub fn is_enabled(&self, id: &str) -> bool {
self.is_enabled_with(id, &Value::Null)
+41 -23
View File
@@ -24,9 +24,49 @@ pub fn neutral_color(mode: BlendMode) -> Option<([f32; 3], &'static str)> {
}
}
/// 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"))),
@@ -62,29 +102,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()),
};
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 }))
Ok(NewLayer { clip, mode, opacity, label, neutral, below, name })
}
#[cfg(test)]
+74
View File
@@ -0,0 +1,74 @@
//! `Session::precheck` accepts exactly what `execute` accepts for the commands it covers
//! (`PRECHECKED`): Free Transform applies its box before them only when they will run (#5859).
use photocraft_engine::{PRECHECKED, Session};
use serde_json::{Value, json};
/// Documents in different states: a Background only, a pixel layer active, a group active, a
/// selection, no document.
fn states() -> Vec<(&'static str, Session)> {
let mut out = Vec::new();
out.push(("no document", Session::new()));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
out.push(("background only", s));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
out.push(("pixel layer", s));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
s.execute("layer.new.group", json!({})).unwrap();
out.push(("group, no background", s));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
out.push(("selection", s));
out
}
fn params() -> Vec<Value> {
vec![
Value::Null,
json!({}),
json!([1]),
json!("x"),
json!({"mode": "Bogus"}),
json!({"mode": "Multiply"}),
json!({"fillNeutral": true}),
json!({"mode": "Overlay", "fillNeutral": true}),
json!({"position": "x"}),
json!({"position": "below"}),
json!({"opacity": "half"}),
json!({"name": 5}),
json!({"name": "ok"}),
json!({"color": "chartreuse"}),
json!({"layer": 999_999}),
json!({"layer": "Layer 1"}),
json!({"layer": "no such layer"}),
json!({"layer": true}),
json!({"ifPresent": true}),
]
}
#[test]
fn precheck_agrees_with_execute() {
let mut disagreements = Vec::new();
for id in PRECHECKED {
for p in params() {
for (state, _) in states() {
// A fresh copy of the state for each call: execute changes it.
let mut s = states().into_iter().find(|(n, _)| *n == state).map(|(_, s)| s).unwrap();
let pre = s.precheck(id, &p).is_ok();
let ran = s.execute(id, p.clone()).is_ok();
if pre != ran {
disagreements.push(format!("{id} {p} on {state}: precheck {pre}, execute {ran}"));
}
}
}
}
assert!(disagreements.is_empty(), "{disagreements:#?}");
}
+17
View File
@@ -1044,6 +1044,21 @@ impl PhotocraftApp {
{
authorize(id, &params)?;
}
// A new layer while Free Transform is open applies the transform first, as Photoshop does
// (#5667): History reads the transform (or Place Embedded), then New Layer. Only for a
// command that can run; a transform refused or still being applied keeps the box open and
// the command doesn't run.
if photocraft_engine::PRECHECKED.contains(&id) && self.ui.transform.is_some() {
// Bad params or a command that can't run leave the box as it is.
self.session.precheck(id, &params).map_err(|e| e.to_string())?;
transform_tool::commit(self);
if transform_tool::committing(self).is_some() {
return Err("the transform is still being applied; try again when it has finished".into());
}
if self.ui.transform.is_some() {
return Err(if self.ui.status.is_empty() { "the transform couldn't be applied".into() } else { self.ui.status.clone() });
}
}
if let Some(result) = panel_docking::command(self, id, &params) {
return result;
}
@@ -2641,6 +2656,8 @@ mod transform_fx_tests;
#[cfg(test)]
mod layer_props_ui_tests;
#[cfg(test)]
mod transform_switch_tests;
#[cfg(test)]
mod transform_undo_tests;
#[cfg(test)]
@@ -0,0 +1,223 @@
//! Free Transform with the whole app: picking another tool or layer, or New Layer, while the box
//! is open applies the transform as it stands, as Photoshop does (#5441, #5665, #5667).
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::PhotocraftApp;
/// The app with a 400 × 300 document: a red 100 × 60 rectangle at (100, 100) on its own layer
/// above the Background.
fn app() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
app
});
h.run_steps(6);
h
}
/// Open Free Transform and move the box 50 px right, 40 px down.
fn transform_and_move(h: &mut Harness<'static, PhotocraftApp>) {
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap();
h.run_steps(2);
let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open");
for p in &mut t.quad {
p[0] += 50.0;
p[1] += 40.0;
}
h.run_steps(2);
}
fn red_bounds(h: &Harness<'static, PhotocraftApp>) -> photocraft_geom::Rect {
let st = h.state().session.active().unwrap();
let top = st.doc.layers.last().unwrap();
top.surface().unwrap().content_bounds()
}
#[test]
fn picking_another_tool_in_the_toolbar_applies_the_transform() {
let mut h = app();
transform_and_move(&mut h);
let hand = h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool");
hand.click();
h.run_steps(4);
assert!(h.state().ui.transform.is_none(), "the box closed");
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "the move was applied, not cancelled");
}
/// The app with the red rectangle on a layer the last step made (Layer via Copy, which deselects in
/// the same step), as Place Embedded leaves it for the box `begin_placed` opens: cancelling the
/// place takes that step back.
fn placed_app() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.run("layer.new.layerViaCopy", json!({})).unwrap();
app
});
h.run_steps(6);
assert!(h.state().session.active().unwrap().doc.selection.is_none(), "Layer via Copy deselects in its own step");
h
}
fn history(h: &Harness<'static, PhotocraftApp>) -> Vec<String> {
h.state().session.active().unwrap().history.entries().to_vec()
}
/// The box on a just-placed layer (a file dropped on the canvas), moved.
fn placed_and_moved(h: &mut Harness<'static, PhotocraftApp>) {
let ctx = h.ctx.clone();
crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap();
h.run_steps(2);
let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open");
for p in &mut t.quad {
p[0] += 50.0;
p[1] += 40.0;
}
h.run_steps(2);
}
#[test]
fn a_placed_image_is_applied_when_another_tool_is_picked() {
let mut h = placed_app();
placed_and_moved(&mut h);
h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool").click();
h.run_steps(4);
assert!(h.state().ui.transform.is_none());
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays");
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200));
}
#[test]
fn picking_another_layer_in_the_panel_applies_the_transform() {
for placed in [false, true] {
let mut h = if placed { placed_app() } else { app() };
if placed {
placed_and_moved(&mut h);
} else {
transform_and_move(&mut h);
}
h.get_by_label("Background").click();
h.run_steps(4);
assert!(h.state().ui.transform.is_none(), "placed {placed}");
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "placed {placed}");
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}");
}
}
#[test]
fn new_layer_applies_the_transform_first() {
for placed in [false, true] {
let mut h = if placed { placed_app() } else { app() };
let before = history(&h);
if placed {
placed_and_moved(&mut h);
} else {
transform_and_move(&mut h);
}
// The Layers panel's New Layer button (the menu item opens the New Layer dialog).
h.get_by_label("Create a new layer").click();
h.run_steps(4);
assert!(h.state().ui.transform.is_none(), "placed {placed}");
let st = h.state().session.active().unwrap();
assert_eq!(st.doc.layers.len(), 3, "placed {placed}");
let red = st.doc.layers[1].surface().unwrap().content_bounds();
assert_eq!(red, photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}: the moved image, not the original");
// History: the transform (folded into Place Embedded for a placed layer), then New Layer.
let after = history(&h);
let added: Vec<&str> = after.iter().skip(before.len().saturating_sub(usize::from(placed))).map(String::as_str).collect();
let first = if placed { photocraft_engine::file_cmds::PLACE_EMBEDDED } else { "Free Transform" };
assert_eq!(added, [first, "New Layer"], "placed {placed}: {after:?}");
}
}
#[test]
fn an_untouched_placed_image_stays_when_another_tool_or_layer_is_picked() {
for via_layer in [false, true] {
let mut h = placed_app();
let ctx = h.ctx.clone();
crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap();
h.run_steps(2);
if via_layer {
let bg = h.state().session.active().unwrap().doc.layers[0].id;
h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap();
} else {
h.query_by_label_contains("Hand Tool").expect("Hand Tool").click();
}
h.run_steps(4);
let st = h.state().session.active().unwrap();
assert_eq!(st.doc.layers.len(), 2, "via layer {via_layer}: the placed image stays");
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160));
}
}
/// The placed fixture is a real place: cancelling the box takes the layer back, so the tests
/// above would see a switch that cancelled it.
#[test]
fn cancelling_the_placed_fixture_removes_its_layer() {
let mut h = placed_app();
placed_and_moved(&mut h);
crate::transform_tool::cancel(h.state_mut());
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 1);
}
/// A new-layer command that can't run (Artboard from Group with a pixel layer active) leaves the box and the
/// placed layer as they were.
#[test]
fn a_new_layer_command_that_cant_run_leaves_the_box_open() {
let mut h = placed_app();
placed_and_moved(&mut h);
let before = history(&h);
assert!(h.state_mut().run("layer.new.artboardFromGroup", json!({})).is_err());
h.run_steps(2);
assert!(h.state().ui.transform.is_some(), "the box stays open");
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays");
assert_eq!(history(&h), before);
}
/// A transform the engine refuses (a result far over the pixel budget, #3773) keeps the box open
/// with the placed layer, and the new layer isn't made: the refusal comes back.
#[test]
fn a_refused_transform_keeps_the_placed_layer_and_refuses_the_new_layer() {
let mut h = placed_app();
placed_and_moved(&mut h);
let t = h.state_mut().ui.transform.as_mut().unwrap();
t.quad = [[0.0, 0.0], [4.0e6, 0.0], [4.0e6, 4.0e6], [0.0, 4.0e6]];
h.run_steps(2);
let before = history(&h);
let r = h.state_mut().run("layer.new.layer", json!({}));
h.run_steps(2);
assert!(r.is_err(), "{r:?}");
assert!(h.state().ui.transform.is_some(), "the box stays open");
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays, no new layer");
assert_eq!(history(&h), before);
}
/// New Layer with bad params, while a moved box is open, is refused before the transform is
/// applied: the box, the layer and History stay as they were.
#[test]
fn new_layer_with_bad_params_leaves_the_box_open() {
for bad in [json!({"mode": "Bogus"}), json!({"fillNeutral": true}), json!({"position": "x"}), json!([1])] {
let mut h = app();
transform_and_move(&mut h);
let before = history(&h);
assert!(h.state_mut().run("layer.new.layer", bad.clone()).is_err(), "{bad}");
h.run_steps(2);
assert!(h.state().ui.transform.is_some(), "{bad}: the box stays open");
assert_eq!(history(&h), before, "{bad}: nothing applied");
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160), "{bad}");
}
}