//! Editing a Pattern Fill layer: Photoshop's Pattern Fill dialog (pattern, Angle, Scale, Link with //! Layer, Snap to Origin) commits one undoable content edit, independent of the layer's masks. use photocraft_doc::{Document, Fill, LayerContent, Pattern}; use serde_json::{Value, json}; use crate::commands::{CommandSpec, check_pixels_unlocked, layer_param}; use crate::{EngineError, Result, Session}; pub const SET: &str = "layer.fill.pattern.set"; /// Layer › New Fill Layer › Pattern… (`pattern_cmds`). pub const CREATE: &str = "layer.newFillLayer.pattern"; /// Photoshop's Scale range in the Pattern Fill dialog (percent). pub const SCALE_RANGE: (f64, f64) = (1.0, 1000.0); /// Largest phase (origin offset) accepted, in pixels. pub(crate) const MAX_PHASE: f64 = 1.0e7; fn bad(msg: impl Into) -> EngineError { EngineError::BadParams { cmd: SET.into(), msg: msg.into() } } /// The pattern fill `p` asks for, each param optional and defaulting to `base`. Returns the new /// fill and the pattern to copy into the document (when `pattern` was given). fn edited(s: &Session, base: &Fill, p: &Value) -> Result<(Fill, Option)> { let Fill::Pattern { name, scale, id, angle, link, phase } = base else { return Err(bad("the target must be a Pattern Fill layer")) }; let num = |k: &str, (min, max): (f64, f64)| -> Result> { match p.get(k) { None | Some(Value::Null) => Ok(None), Some(v) => v .as_f64() .filter(|v| v.is_finite() && (min..=max).contains(v)) .map(|v| Some(v as f32)) .ok_or_else(|| bad(format!("`{k}` must be a number within {min}..{max}"))), } }; let (mut scale, mut angle, mut link, mut phase, mut pat) = (*scale, *angle, *link, *phase, None); if let Some(v) = p.get("pattern").filter(|v| !v.is_null()) { let key = v.as_str().filter(|k| !k.is_empty()).ok_or_else(|| bad("`pattern` is a pattern id or name"))?; pat = Some(crate::pattern_cmds::resolve(s, key).ok_or_else(|| bad(format!("no pattern \"{key}\" (see pattern.list)")))?); } if let Some(v) = num("scale", SCALE_RANGE)? { scale = v / 100.0; } if let Some(v) = num("angle", (-180.0, 180.0))? { angle = v; } match p.get("link") { None | Some(Value::Null) => {} Some(v) => link = v.as_bool().ok_or_else(|| bad("`link` is true or false"))?, } match p.get("phase") { None | Some(Value::Null) => {} Some(v) => { let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad("`phase` is [x, y]"))?; let c = |i: usize| a.get(i).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() <= MAX_PHASE).map(|v| v as f32); let (Some(x), Some(y)) = (c(0), c(1)) else { return Err(bad(format!("`phase` needs finite coordinates within ±{MAX_PHASE}"))) }; phase = (x, y); } } let (name, id) = match &pat { Some(pat) => (pat.name.clone(), pat.id.clone()), None => (name.clone(), id.clone()), }; Ok((Fill::Pattern { name, scale, id, angle, link, phase }, pat)) } /// The target layer and the fill `p` gives it, checked against the active document. fn target(s: &Session, p: &Value) -> Result<(photocraft_doc::LayerId, Fill, Fill, Option)> { if !p.is_object() || p.get("layer").is_some_and(|v| v.as_u64().is_none()) { return Err(bad("params must be an object and layer an unsigned id")); } let st = s.active().ok_or(EngineError::NoDocument)?; if let Some(document) = p.get("document") && document.as_u64() != Some(st.doc.id.0) { return Err(bad("the target document is no longer active")); } let id = layer_param(s, p)?; let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?; let LayerContent::Fill(original @ Fill::Pattern { .. }) = &layer.content else { return Err(bad("the target must be a Pattern Fill layer")) }; let (fill, pat) = edited(s, original, p)?; Ok((id, original.clone(), fill, pat)) } fn set(s: &mut Session, p: &Value) -> Result { let (id, original, fill, pat) = target(s, p)?; if fill != original { let st = s.active().ok_or(EngineError::NoDocument)?; check_pixels_unlocked(&st.doc, id)?; s.edit("Pattern Fill", |doc, _| { if let Some(pat) = &pat { crate::pattern_cmds::ensure_in_doc(doc, pat); } doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.content = LayerContent::Fill(fill.clone()); Ok(()) })?; } Ok(info(id, &fill)) } fn info(layer: photocraft_doc::LayerId, fill: &Fill) -> Value { match fill { Fill::Pattern { scale, id, angle, link, phase, .. } => { json!({"layer": layer.0, "pattern": id, "scale": scale * 100.0, "angle": angle, "link": link, "phase": [phase.0, phase.1]}) } _ => json!({"layer": layer.0}), } } /// The active document as [`SET`] or [`CREATE`] with `p` would leave it, without a history /// step: the Pattern Fill dialog's live preview, so it shows exactly what OK commits. pub fn preview(s: &Session, cmd: &str, p: &Value) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let mut doc = (*st.doc).clone(); match cmd { SET => { let (id, _, fill, pat) = target(s, p)?; if let Some(pat) = &pat { crate::pattern_cmds::ensure_in_doc(&mut doc, pat); } doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.content = LayerContent::Fill(fill); } CREATE => { let pat = crate::pattern_cmds::resolve_param(s, CREATE, p)?; let mut active = st.active_layer; let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true); crate::pattern_cmds::insert_fill_layer(&mut doc, &mut active, &pat, p, default_mask)?; } other => return Err(EngineError::BadParams { cmd: other.into(), msg: "no Pattern Fill preview for this command".into() }), } Ok(doc) } /// Register `layer.fill.pattern.set` (the Pattern Fill dialog's command), journaled like any other edit. pub fn specs() -> Vec { vec![CommandSpec { id: SET, label: "Pattern Fill…", menu: &[], shortcut: None, params: r##"{"layer":id? (default active; a Pattern Fill layer),"document":id? (reject a stale dialog),"pattern":id|name?,"scale":1..1000?,"angle":-180..180?,"link":bool?,"phase":[x,y]? (Snap to Origin: [0,0])}; omitted values keep the layer's"##, enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()), run: set, journal: true, }] } #[cfg(test)] mod tests { use super::*; use photocraft_doc::LayerId; use std::sync::Arc; fn session(depth: u64) -> (Session, LayerId) { let mut s = Session::new(); s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); s.execute("select.rect", json!({"x": 2, "y": 2, "width": 20, "height": 10})).unwrap(); let id = s.execute(CREATE, json!({"pattern": "Checkerboard"})).unwrap()["layer"].as_u64().unwrap(); (s, LayerId(id)) } fn fill(s: &Session, id: LayerId) -> Fill { let LayerContent::Fill(f) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a fill") }; f.clone() } #[test] fn edit_keeps_masks_is_one_step_and_undoes_at_every_depth() { for depth in [8, 16, 32] { let (mut s, id) = session(depth); let before = s.active().unwrap().doc.clone(); let count = s.active().unwrap().history.entries().len(); let r = s.execute(SET, json!({"layer": id.0, "pattern": "Bricks", "scale": 250, "angle": -45, "link": false, "phase": [3, -4]})).unwrap(); assert_eq!(r["scale"], json!(250.0)); let bricks = crate::pattern_cmds::resolve(&s, "Bricks").unwrap(); assert_eq!( fill(&s, id), Fill::Pattern { name: bricks.name.clone(), scale: 2.5, id: bricks.id.clone(), angle: -45.0, link: false, phase: (3.0, -4.0) } ); let st = s.active().unwrap(); assert_eq!(st.history.entries().len(), count + 1); assert!(st.doc.patterns.iter().any(|p| p.id == bricks.id), "the new pattern is copied into the document"); assert_eq!(st.doc.layer(id).unwrap().mask, before.layer(id).unwrap().mask, "the selection mask is kept"); // Omitted values keep the layer's; Snap to Origin zeroes the phase. s.execute(SET, json!({"phase": [0, 0]})).unwrap(); let Fill::Pattern { scale, angle, phase, link, .. } = fill(&s, id) else { panic!() }; assert_eq!((scale, angle, phase, link), (2.5, -45.0, (0.0, 0.0), false)); s.undo(); s.undo(); assert_eq!(*s.active().unwrap().doc, *before); s.redo(); assert_eq!(fill(&s, id), Fill::Pattern { name: bricks.name.clone(), scale: 2.5, id: bricks.id, angle: -45.0, link: false, phase: (3.0, -4.0) }); let count = s.active().unwrap().history.entries().len(); s.execute(SET, json!({"scale": 250, "angle": -45})).unwrap(); assert_eq!(s.active().unwrap().history.entries().len(), count, "an unchanged fill is not a history step"); } } #[test] fn preview_matches_what_the_command_commits() { let (mut s, id) = session(8); let p = json!({"layer": id.0, "pattern": "Dots", "scale": 50, "angle": 30}); let (before, count) = (s.active().unwrap().doc.clone(), s.active().unwrap().history.entries().len()); let shown = preview(&s, SET, &p).unwrap(); assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)); assert_eq!(s.active().unwrap().history.entries().len(), count, "previewing is not an edit"); s.execute(SET, p).unwrap(); assert_eq!(shown, *s.active().unwrap().doc); let p = json!({"pattern": "Grid", "scale": 300, "angle": 90, "link": false}); let shown = preview(&s, CREATE, &p).unwrap(); s.execute(CREATE, p).unwrap(); // Layer ids are allocated per process, so the preview's new layer has its own id: compare // what the documents look like. let all = photocraft_geom::Rect::new(0, 0, 40, 30); assert_eq!(photocraft_compose::render(&shown, all).px, photocraft_compose::render(&s.active().unwrap().doc, all).px); assert_eq!(shown.layers.len(), s.active().unwrap().doc.layers.len()); assert!(preview(&s, "edit.fill", &json!({})).is_err()); } /// At 8, 16 and 32 bits the Pattern Fill preview and the committed layer carry the default mask only while the option is on, and creating one is one undoable step. #[test] fn create_preview_and_commit_honor_default_masks_option() { for depth in [8, 16, 32] { for default_mask in [false, true] { let mut s = Session::new(); s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); s.edit_prefs(|prefs| { prefs.dialogs.insert("ui.layersPanelOptions".into(), json!({"useDefaultMasksOnFillLayers": default_mask})); }); let before = s.active().unwrap().doc.clone(); let count = s.active().unwrap().history.entries().len(); let p = json!({"pattern": "Grid", "scale": 50}); let shown = preview(&s, CREATE, &p).unwrap(); assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)); assert_eq!(s.active().unwrap().history.entries().len(), count); let previewed = shown.layers.iter().find(|l| matches!(l.content, LayerContent::Fill(Fill::Pattern { .. }))).unwrap(); assert_eq!(previewed.mask.is_some(), default_mask); if let Some(mask) = &previewed.mask { assert_eq!(mask.surface.format(), photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Grayscale, shown.depth, false)); } let id = LayerId(s.execute(CREATE, p).unwrap()["layer"].as_u64().unwrap()); let committed = s.active().unwrap().doc.layer(id).unwrap(); assert_eq!(committed.mask, previewed.mask); assert_eq!(committed.content, previewed.content); s.undo(); assert_eq!(*s.active().unwrap().doc, *before); s.redo(); assert_eq!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some(), default_mask); } } } /// Malformed or out-of-range params, and missing layers or patterns, are errors in both the command and its preview, never panics. #[test] fn bad_params_and_targets_are_errors_not_panics() { let (mut s, id) = session(8); let before = s.active().unwrap().doc.clone(); for p in [ json!(null), json!([1, 2]), json!({"layer": -1}), json!({"layer": "x"}), json!({"layer": u64::MAX}), json!({"document": 999_999}), json!({"pattern": "nope"}), json!({"pattern": ""}), json!({"pattern": 5}), json!({"scale": 0}), json!({"scale": 1001}), json!({"scale": "big"}), json!({"angle": 181}), json!({"angle": -1e300}), json!({"link": "yes"}), json!({"phase": [1]}), json!({"phase": [1, "x"]}), json!({"phase": [1e30, 0]}), json!({"phase": {"x": 1}}), ] { assert!(s.execute(SET, p.clone()).is_err(), "{p}"); assert!(preview(&s, SET, &p).is_err(), "{p}"); } s.execute("layer.new.layer", json!({})).unwrap(); assert!(s.execute(SET, json!({"scale": 50})).is_err(), "a pixel layer is not a Pattern Fill"); s.execute("layer.newFillLayer.solidColor", json!({})).unwrap(); assert!(s.execute(SET, json!({"scale": 50})).is_err(), "a Solid Color Fill is not a Pattern Fill"); assert_eq!(s.active().unwrap().doc.layer(id), before.layer(id)); assert!(Session::new().execute(SET, json!({})).is_err(), "no document"); } #[test] fn locked_layer_is_refused() { let (mut s, id) = session(8); s.execute("layer.lockLayers", json!({"pixels": true})).unwrap(); assert!(s.execute(SET, json!({"layer": id.0, "scale": 50})).is_err()); } }