mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 04:06:53 -04:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9e1c10c30 | ||
|
|
4a1acca879 | ||
|
|
a95b3f0a85 | ||
|
|
b7eab1a377 | ||
|
|
25a8200110 | ||
|
|
6443f5fb4c |
No files matched your search
@@ -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, ¶ms).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, ¶ms) {
|
||||
return Err(EngineError::Other(why.into()));
|
||||
}
|
||||
let run_params = self.admit(id, spec, ¶ms)?;
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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:#?}");
|
||||
}
|
||||
@@ -1044,6 +1044,21 @@ impl PhotocraftApp {
|
||||
{
|
||||
authorize(id, ¶ms)?;
|
||||
}
|
||||
// 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, ¶ms).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, ¶ms) {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user