//! Everyday Photoshop commands that build on the core ones: Edit › Stroke, the fixed Transform //! presets (Rotate 180°/90°, Flip), Paste Into, Reselect, Equalize, Reveal All, Layer from //! Background, Copy/Paste Layer Style, Hide All Effects, layer-mask toggles, Rasterize variants, //! Delete Hidden / Empty Layers, Ungroup, Hide/Show Layers, Show Only This Layer (⌥-click an eye), //! Average and Clouds. use photocraft_algo::selection as sel; use photocraft_doc::{Document, Layer, LayerContent, LayerId, LayerMask}; use photocraft_geom::Rect; use photocraft_raster::{from_rgba_into, to_rgba}; use serde_json::{Value, json}; use crate::commands::{CommandSpec, color_param, layer_param}; use crate::{EngineError, Result, Session}; fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } fn active_layer(s: &Session) -> std::result::Result<&Layer, String> { let d = s.active().ok_or("no document open")?; d.active_layer.and_then(|id| d.doc.layer(id)).ok_or_else(|| "no active layer".into()) } fn has_layer(s: &Session) -> std::result::Result<(), String> { active_layer(s).map(|_| ()) } fn has_pixels(s: &Session) -> std::result::Result<(), String> { match active_layer(s)?.content { LayerContent::Raster(_) => Ok(()), ref c => Err(format!("active layer is {} {} layer, not a pixel layer", c.article(), c.kind_name())), } } fn has_mask(s: &Session) -> std::result::Result<(), String> { active_layer(s)?.mask.as_ref().map(|_| ()).ok_or_else(|| "the active layer has no layer mask".into()) } fn has_clip_and_selection(s: &Session) -> std::result::Result<(), String> { let d = s.active().ok_or("no document open")?; s.clipboard.as_ref().ok_or("the clipboard is empty")?; d.doc.selection.as_ref().map(|_| ()).ok_or_else(|| "Paste Into needs a selection".into()) } fn has_background(s: &Session) -> std::result::Result<(), String> { let d = s.active().ok_or("no document open")?; d.doc.background().map(|_| ()).ok_or_else(|| "the document has no Background layer".into()) } /// The layers a layer-style command acts on: as [`crate::layer_multi_cmds::targets`], but with /// several layers selected only those `fits` (the Background carries no style, Clear needs one). pub(crate) fn style_targets(s: &Session, p: &Value, fits: impl Fn(&Layer) -> bool) -> Result> { let ids = crate::layer_multi_cmds::targets(s, p)?; if ids.len() < 2 { return Ok(ids); } let d = s.active().ok_or(EngineError::NoDocument)?; let ids: Vec = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !d.doc.is_background(l.id) && fits(l))).collect(); if ids.is_empty() { return Err(EngineError::Other("none of the selected layers can take this layer style change".into())); } Ok(ids) } /// A key that makes the sub-commands of one command share a single history step. pub(crate) fn step_key(s: &Session, what: &str) -> String { format!("{what}#{}", s.active().map_or(0, |d| d.revision)) } // ---------- pixel helpers ---------- /// Rewrite the active pixel layer's RGBA over the selection bounds (or the canvas). `f` gets the /// area, the pixels, and the selection coverage per pixel; results are blended back by coverage, /// and transparency stays locked when the layer locks it. When `p` targets an alpha channel, the /// Quick Mask or the layer mask (`Session::execute` fills that in from the Channels panel), that /// grayscale surface is rewritten instead, as the core filters do (#935). fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result { let id = layer_param(s, &Value::Null)?; let channel = crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels; s.edit(label, |doc, _| { let canvas = doc.bounds(); let selection = doc.selection.clone(); let area = selection.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas)); let (surf, lock_alpha) = if channel { let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, Some(id), p)? else { return Ok(()) }; (surf, false) } else { let locks = doc.effective_locks(id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if locks.pixels || locks.all { return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name))); } (l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?, locks.transparency) }; if area.is_empty() { return Ok(()); } let fmt = surf.format(); let n = fmt.channels(); let mut raw = surf.read_region(area); let orig: Vec<[f32; 4]> = raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect(); let w = area.width() as usize; let cover: Vec = match &selection { Some(m) => (0..orig.len()).map(|i| m.sample_channel(area.x0 + (i % w) as i32, area.y0 + (i / w) as i32, 0)).collect(), None => vec![1.0; orig.len()], }; let mut px = orig.clone(); f(area, &mut px, &cover); let mut enc = [0.0f32; 8]; for (i, out) in raw.chunks_exact_mut(n).enumerate() { let k = cover[i]; if k <= 0.0 { continue; } let (o, p) = (orig[i], px[i]); let mut v = [0.0; 4]; for c in 0..4 { v[c] = o[c] + (p[c] - o[c]) * k; } if lock_alpha { v[3] = o[3]; } from_rgba_into(&fmt, v, &mut enc); out.copy_from_slice(&enc[..n]); } surf.write_region(area, &raw); surf.prune(); Ok(()) })?; Ok(Value::Null) } /// The effective whole-pixel Stroke width, shared by execution and ATN export. pub(crate) fn stroke_width(p: &Value) -> usize { // Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px). p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize } /// Edit › Stroke: a band along the selection edge (or the layer's opaque edge without a selection). fn stroke(s: &mut Session, p: &Value) -> Result { let width = stroke_width(p); let color = color_param("edit.stroke", p, "color", s.tools.foreground)?; // A colour picked as CMYK strokes a CMYK document with the picked inks (#4196). let picked = s.tools.picked_cmyk([color[0], color[1], color[2]]); let mode = match p.get("mode") { None | Some(Value::Null) => photocraft_color::BlendMode::Normal, Some(Value::String(m)) => crate::commands::blend_from_str(m) .filter(|m| *m != photocraft_color::BlendMode::PassThrough) .ok_or_else(|| EngineError::BadParams { cmd: "edit.stroke".into(), msg: format!("unknown blend mode `{m}`") })?, Some(v) => return Err(EngineError::BadParams { cmd: "edit.stroke".into(), msg: format!("mode must be a blend mode name, not {v}") }), }; let opacity = match p.get("opacity") { None | Some(Value::Null) => 1.0, Some(v) => { (v.as_f64() .filter(|x| x.is_finite()) .ok_or_else(|| EngineError::BadParams { cmd: "edit.stroke".into(), msg: "opacity must be a finite number from 0 to 100".into() })? .clamp(0.0, 100.0) / 100.0) as f32 } }; let preserve = match p.get("preserveTransparency") { None | Some(Value::Null) => false, Some(Value::Bool(v)) => *v, Some(_) => return Err(EngineError::BadParams { cmd: "edit.stroke".into(), msg: "preserveTransparency must be true or false".into() }), }; let location = p.get("location").and_then(Value::as_str).unwrap_or("center").to_string(); let id = layer_param(s, p)?; s.edit("Stroke", |doc, _| { let canvas = doc.bounds(); let (w, h) = (canvas.width() as usize, canvas.height() as usize); let edge = match &doc.selection { Some(m) => sel::mask_from_surface(Some(m), canvas), None => { let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; let surf = l.surface().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?; let mut px = vec![[0.0f32; 4]; w * h]; surf.read_rgba_into(canvas, &mut px); px.iter().map(|p| p[3]).collect() } }; // Past the canvas nothing is selected, so the canvas edge is a selection edge like any // other (Select All, then Stroke › Inside, frames the canvas): stroke on a padded copy. let pad = width + 1; let (pw, ph) = (w + 2 * pad, h + 2 * pad); let mut padded = vec![0.0f32; pw * ph]; for (row, src) in padded.chunks_exact_mut(pw).skip(pad).zip(edge.chunks_exact(w.max(1))) { if let Some(dst) = row.get_mut(pad..pad + w) { dst.copy_from_slice(src); } } // Whole-pixel bands, fully opaque: Center puts the odd pixel inside (1 px is all inside). let (out_px, in_px) = match location.as_str() { "inside" => (0, width), "outside" => (width, 0), _ => (width / 2, width.div_ceil(2)), }; // A hard-edged selection strokes hard, matching Photoshop pixel for pixel on curves: // outside, a pixel is stroked when its centre is less than width + 1 from a selected // pixel's; inside, less than width + ½ from an unselected pixel's (fitted to Photoshop's // strokes of a 14 px circle: 6 px Outside; 3, 5 and 6 px Inside). Both give whole // pixels on straight edges. Soft selections keep soft edges. let hard = padded.iter().all(|v| *v <= 0.0 || *v >= 1.0); let in_r = if hard && in_px > 0 { in_px as f32 - 0.5 } else { in_px as f32 }; let mut outer = sel::expand(&padded, pw, ph, out_px as f32); let mut inner = sel::contract(&padded, pw, ph, in_r); if hard { outer.iter_mut().for_each(|v| *v = if *v > 0.0 { 1.0 } else { 0.0 }); inner.iter_mut().for_each(|v| *v = if *v >= 1.0 { 1.0 } else { 0.0 }); } let band: Vec = outer .chunks_exact(pw) .zip(inner.chunks_exact(pw)) .skip(pad) .take(h) .flat_map(|(o, i)| o.iter().zip(i).skip(pad).take(w).map(|(o, i)| (o - i).clamp(0.0, 1.0))) .collect(); let band = sel::mask_to_surface(&band, canvas); let lock = doc.effective_locks(id).transparency; let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; let area = band.content_bounds().intersect(&canvas); if !area.is_empty() { crate::fill_cmds::blend_color_mask(surf, area, color, picked, &band, mode, opacity, preserve || lock)?; surf.prune(); } Ok(()) })?; Ok(Value::Null) } /// Histogram equalization, like Photoshop's: one CDF over the R, G and B values of /// the selected pixels, applied to every channel. fn equalize(s: &mut Session, p: &Value) -> Result { map_pixels(s, "Equalize", p, |_, px, cover| { let mut hist = [0f64; 256]; for (p, &k) in px.iter().zip(cover) { if k > 0.0 && p[3] > 0.0 { for &v in &p[..3] { hist[(v.clamp(0.0, 1.0) * 255.0).round() as usize] += 1.0; } } } let total: f64 = hist.iter().sum(); if total == 0.0 { return; } let first = hist.iter().copied().find(|v| *v > 0.0).unwrap_or(0.0); let mut lut = [0f32; 256]; let mut acc = 0.0; for (i, h) in hist.iter().enumerate() { acc += h; lut[i] = if total > first { ((acc - first) / (total - first)).clamp(0.0, 1.0) as f32 } else { i as f32 / 255.0 }; } // Same 256 bins as the histogram, at every depth (like Photoshop). let map = |v: f32| lut[(v.clamp(0.0, 1.0) * 255.0).round() as usize]; for p in px.iter_mut() { for v in &mut p[..3] { *v = map(*v); } } }) } /// Filter › Blur › Average: the selection (or layer) filled with its mean colour. fn average(s: &mut Session, p: &Value) -> Result { map_pixels(s, "Average", p, |_, px, cover| { let (mut sum, mut wsum, mut asum, mut n) = ([0f64; 3], 0f64, 0f64, 0f64); for (p, &k) in px.iter().zip(cover) { let w = (p[3] * k) as f64; for c in 0..3 { sum[c] += p[c] as f64 * w; } wsum += w; asum += (p[3] * k) as f64; n += k as f64; } if wsum <= 0.0 { return; } let avg = [(sum[0] / wsum) as f32, (sum[1] / wsum) as f32, (sum[2] / wsum) as f32, (asum / n.max(1e-9)) as f32]; px.fill(avg); }) } // ---------- transform presets ---------- /// The presets' params: like Free Transform, they follow the Channels and Layers panel target. const TRANSFORM_TARGET: &str = r#"{"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask turns alone)}"#; /// Flips and turns about the centre of the box Free Transform would open: with a selection /// limiting the transform it is the selection's own bounds, as in Photoshop, so Select All /// mirrors across the canvas (#6928); otherwise the content's bounds. fn transform_preset(s: &mut Session, p: &Value, kind: &str) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; // A targeted alpha channel, the Quick Mask or an unlinked layer mask turns by itself (#2835). let (b, mut params) = match crate::transform_cmds::lone_target(&d.doc, d.active_layer, p)? { Some(surf) => (crate::transform_cmds::target_frame(&d.doc, surf), json!({"target": p.get("target")})), None => { let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?; let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; (crate::transform_cmds::transform_frame(&d.doc, l), json!({"layer": id.0})) } }; if b.is_empty() { return Err(EngineError::Other("nothing to transform".into())); } // Integer offsets keep these exact pixel permutations on the grid (nearest sampling). let (sx, sy) = ((b.x0 + b.x1) as f64, (b.y0 + b.y1) as f64); let (cx, cy) = (sx / 2.0, sy / 2.0); let m: [f64; 6] = match kind { "rotate180" => [-1.0, 0.0, 0.0, -1.0, sx, sy], "rotate90Cw" => [0.0, 1.0, -1.0, 0.0, (cx + cy).round(), (cy - cx).round()], "rotate90Ccw" => [0.0, -1.0, 1.0, 0.0, (cx - cy).round(), (cx + cy).round()], "flipHorizontal" => [-1.0, 0.0, 0.0, 1.0, sx, 0.0], _ => [1.0, 0.0, 0.0, -1.0, 0.0, sy], }; params["matrix"] = json!(m); params["interpolation"] = json!("nearest"); s.execute("edit.transform", params) } // ---------- selection / clipboard ---------- fn reselect_target(s: &Session) -> Option { let d = s.active()?; if d.doc.selection.is_some() { return None; } (0..d.history.past_len()).rev().find_map(|i| d.history.state(i).and_then(|doc| doc.selection.clone())) } fn can_reselect(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; reselect_target(s).map(|_| ()).ok_or_else(|| "there is no selection to restore".into()) } /// Edit › Paste Special › Paste Into (or Outside): the clipboard centred on the selection, not /// clamped to the canvas, as a new layer masked to the selection (or its inverse), or into the /// targeted mask or channel. One history step. fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let selection = d.doc.selection.clone().ok_or(EngineError::Other("Paste Into needs a selection".into()))?; let canvas = d.doc.bounds(); let b = selection.content_bounds(); // Where the paste shows: the selection, or everything but it (default reveal, the // selection's area inverted). let limit = if outside { let area = b.intersect(&canvas); let inv: Vec = sel::mask_from_surface(Some(&selection), area).iter().map(|v| 1.0 - v).collect(); let mut m = photocraft_raster::Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0]); m.write_region(area, &inv); m } else { selection }; let label = if outside { "Paste Outside" } else { "Paste Into" }; // Centred on the selection, even where that runs off the canvas (not clamped like a paste). let mut params = json!({"center": [(b.x0 + b.x1) as f64 / 2.0, (b.y0 + b.y1) as f64 / 2.0], "clampToCanvas": false}); if let Some(c) = p.get("center") { params["center"] = c.clone(); } // A targeted colour channel (#2307): the paste goes into it, only where `limit` allows. if let Some(k) = crate::channel_clip::paste_color_target(s, p) { return crate::channel_clip::paste(s, ¶ms, k, false, Some(&limit), label); } if crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels { // A targeted mask or channel (#1035): the paste goes into it, only where `limit` allows. params["target"] = p.get("target").cloned().unwrap_or_default(); return crate::edit_cmds::paste_to_target(s, ¶ms, false, Some(&limit), label); } // The pasted layer lands beside the active one, inside its group, and a Lock-All group refuses // its layer mask: refuse before pasting rather than leave an unmasked paste behind. let d = s.active().ok_or(EngineError::NoDocument)?; let group = d.active_layer.and_then(|a| d.doc.path_of(a)).and_then(|p| d.doc.layer_at(p.split_last()?.1).map(|g| g.id)); if let Some(g) = group { crate::commands::check_all_unlocked(&d.doc, g)?; } let key = step_key(s, "pasteInto"); params["coalesce"] = json!(key); params["target"] = json!("pixels"); let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let r = s.execute("edit.paste", params)?; let id = layer_param(s, &Value::Null)?; s.execute("layer.layerMask.revealAll", json!({"layer": id.0, "coalesce": key}))?; s.coalesce_request = Some(key); let r2 = s.edit(label, |doc, _| { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.mask = Some(LayerMask { surface: limit, linked: false, ..LayerMask::reveal_all() }); Ok(()) }); s.coalesce_request = None; r2?; // Only where the masked paste draws needs recompositing (#2901). crate::layer_multi_cmds::note_insert(s, &before); Ok(r) } /// The canvas grown to every layer's pixels: Image › Reveal All's new canvas, and what the Crop /// tool shows while a frame is edited. pub fn reveal_all_bounds(doc: &Document) -> Rect { doc.walk().into_iter().filter_map(|(_, _, l)| l.surface().map(|s| s.content_bounds())).fold(doc.bounds(), |a, b| a.union(&b)) } // ---------- layers ---------- fn layer_from_background(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; if d.doc.background().is_none() && p.get("ifPresent").and_then(Value::as_bool) == Some(true) { return Ok(json!({"layer": null})); } // `select: false`: the targeted layer stays targeted, as when a Photoshop action's `set` // names the Background while another layer is selected. let select = p.get("select").and_then(Value::as_bool).unwrap_or(true); let id = s.edit("Layer From Background", |doc, active| { let l = doc.background_mut().ok_or(EngineError::Other("the document has no Background layer".into()))?; unlock_background(l); if select || active.is_none() { *active = Some(l.id); } Ok(l.id) })?; Ok(json!({"layer": id.0})) } pub(crate) fn unlock_background(l: &mut Layer) { l.name = "Layer 0".into(); l.locks.transparency = false; l.locks.position = false; } /// Before `id` gets a layer mask: the Background can't have one, so it becomes a normal layer /// first ("Layer 0"), as when adding a mask to it in Photoshop. Other layers are left alone. pub(crate) fn background_to_layer_for_mask(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) { background_to_layer(doc, id); } /// If `id` is the Background ([`photocraft_doc::Document::background`], whatever its name), turns /// it into a normal layer ("Layer 0"), as Photoshop does before an edit the Background can't /// take (moving, transforming, exposing transparency, a style). Other layers are left alone. pub(crate) fn background_to_layer(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) { if let Some(l) = doc.background_mut().filter(|l| l.id == id) { unlock_background(l); } } fn toggle_mask(s: &mut Session, p: &Value, key: &str) -> Result { let id = layer_param(s, p)?; let label = if key == "enabled" { "Enable/Disable Layer Mask" } else { "Link/Unlink Layer Mask" }; let v = s.edit(label, |doc, _| { let m = doc.layer_mut(id).and_then(|l| l.mask.as_mut()).ok_or(EngineError::Other("the layer has no layer mask".into()))?; let slot = if key == "enabled" { &mut m.enabled } else { &mut m.linked }; *slot = p.get(key).and_then(Value::as_bool).unwrap_or(!*slot); Ok(*slot) })?; Ok(json!({ key: v })) } fn set_all_effects(s: &mut Session, enabled: bool) -> Result { s.edit(if enabled { "Show All Effects" } else { "Hide All Effects" }, |doc, _| { fn rec(ls: &mut [Layer], on: bool) { for l in ls { if !l.effects.items.is_empty() { l.effects.enabled = on; } if let Some(ch) = l.children_mut() { rec(ch, on); } } } rec(&mut doc.layers, enabled); Ok(()) })?; Ok(Value::Null) } /// Remove every layer matching `pred` (whole subtrees), keeping at least one layer. fn delete_where(s: &mut Session, label: &str, pred: fn(&Layer) -> bool) -> Result { let n = s.edit(label, |doc, active| { fn rec(ls: &mut Vec, pred: fn(&Layer) -> bool) -> usize { let before = ls.len(); ls.retain(|l| !pred(l)); let mut n = before - ls.len(); for l in ls.iter_mut() { if let Some(ch) = l.children_mut() { n += rec(ch, pred); } } n } let backup = doc.layers.clone(); // The clip groups of the doomed layers, read before they go (clip_group rule 3: layers // clipped to a deleted base are released). let doomed: Vec = doc.walk().into_iter().filter(|(_, _, l)| pred(l)).map(|(_, _, l)| l.id).collect(); let before = crate::clip_group::Before::read(doc, &doomed); let n = rec(&mut doc.layers, pred); if doc.layers.is_empty() { doc.layers = backup; return Err(EngineError::Other("a document must keep at least one layer".into())); } crate::clip_group::settle(doc, &[], &before, false); if active.is_none_or(|id| doc.layer(id).is_none()) { *active = doc.top_layer(); } Ok(n) })?; Ok(json!({"deleted": n})) } fn is_empty_layer(l: &Layer) -> bool { match &l.content { LayerContent::Raster(s) => s.content_bounds().is_empty() && l.mask.is_none() && l.effects.items.is_empty(), LayerContent::Group(g) => g.children.iter().all(is_empty_layer), _ => false, } } fn ungroup(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; s.edit("Ungroup Layers", |doc, active| { let path = doc.path_of(id).ok_or(EngineError::NoLayer(id))?; let g = doc.layer(id).ok_or(EngineError::NoLayer(id))?; let LayerContent::Group(group) = &g.content else { return Err(EngineError::Other("the active layer is not a group".into())); }; let children = group.children.clone(); let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(id))?; let sib = if parent.is_empty() { &mut doc.layers } else { doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or(EngineError::NoLayer(id))? }; sib.remove(idx); let top = children.last().map(|l| l.id); for (k, l) in children.into_iter().enumerate() { sib.insert(idx + k, l); } *active = top.or_else(|| doc.top_layer()); Ok(()) })?; Ok(Value::Null) } fn set_visible(s: &mut Session, p: &Value, visible: bool) -> Result { if crate::layer_multi_cmds::multi(s, p) { return crate::layer_multi_cmds::set_visible_selected(s, visible); } let id = layer_param(s, p)?; s.edit(if visible { "Show Layer" } else { "Hide Layer" }, |doc, _| { doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.visible = visible; Ok(()) })?; Ok(Value::Null) } /// Layer › Hide Layers (⌘,): hide the targeted layers, or show them when every one is already /// hidden, as Photoshop's item does (it reads Show Layers then), so ⌘, toggles. fn hide_or_show(s: &mut Session, p: &Value) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let targets = if crate::layer_multi_cmds::multi(s, p) { crate::layer_multi_cmds::selected(s) } else { vec![layer_param(s, p)?] }; let all_hidden = targets.iter().all(|id| st.doc.layer(*id).is_some_and(|l| !l.visible)); set_visible(s, p, all_hidden) } /// `/`: Lock Transparent Pixels on or off for the targeted layers, as the Layers panel's first lock /// button does. With several layers targeted, any unlocked one means lock them all. The /// Background, which can't take this lock, is left out. fn toggle_lock_transparency(s: &mut Session, p: &Value) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let targets: Vec = if crate::layer_multi_cmds::multi(s, p) { crate::layer_multi_cmds::selected(s) } else { vec![layer_param(s, p)?] }; let targets: Vec = targets.into_iter().filter(|id| st.doc.layer(*id).is_some_and(|l| !st.doc.is_background(l.id))).collect(); if targets.is_empty() { return Err(EngineError::Other("the Background layer can't lock transparent pixels".into())); } let lock = !targets.iter().all(|id| st.doc.layer(*id).is_some_and(|l| l.locks.transparency)); s.edit(if lock { "Lock Transparent Pixels" } else { "Unlock Transparent Pixels" }, |doc, _| { for id in &targets { doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.locks.transparency = lock; } Ok(()) })?; Ok(json!({"locked": lock})) } /// Is the layer at `path` shown alone: it and its enclosing groups visible, every layer outside /// it hidden (the layers inside a group keep their own visibility)? fn shown_alone(doc: &Document, path: &[usize]) -> bool { doc.walk().iter().all(|(p, _, l)| if path.starts_with(p) { l.visible } else { p.starts_with(path) || !l.visible }) } /// ⌥-click on a layer's eye: show only that layer (and its enclosing groups), or, when it already /// is shown alone, restore every layer's visibility from before (every layer shown when there is /// no snapshot). ⌥-clicking another eye while one layer is shown alone moves the solo and keeps /// the snapshot. One history step either way; the snapshot is view state. fn show_only(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; let st = s.active().ok_or(EngineError::NoDocument)?; let doc = &st.doc; let path = doc.path_of(id).ok_or(EngineError::NoLayer(id))?; // The snapshot only counts while its solo is still in effect (an undo or a plain eye click ends it). let saved = st.show_only.as_ref().filter(|(prev, _)| doc.path_of(*prev).is_some_and(|pp| shown_alone(doc, &pp))); let walk = doc.walk(); let solo = !shown_alone(doc, &path); let (label, rows, next): (_, Vec<(Vec, bool)>, _) = if solo { let snapshot = saved.map_or_else(|| walk.iter().map(|(_, _, l)| (l.id, l.visible)).collect(), |(_, v)| v.clone()); // Layers inside the shown layer keep their visibility. let rows = walk.iter().filter(|(p, _, _)| p.len() <= path.len() || !p.starts_with(&path)).map(|(p, _, _)| (p.clone(), path.starts_with(p))).collect(); ("Show Only This Layer", rows, Some((id, snapshot))) } else { let before: Option> = saved.map(|(_, v)| v.iter().copied().collect()); // Layers added since the snapshot keep their current visibility. let rows = walk.iter().map(|(p, _, l)| (p.clone(), before.as_ref().is_none_or(|b| b.get(&l.id).copied().unwrap_or(l.visible)))).collect(); ("Show Layers", rows, None) }; s.edit(label, |doc, _| { for (p, visible) in &rows { if let Some(l) = doc.layer_at_mut(p) { l.visible = *visible; } } Ok(()) })?; if let Some(st) = s.active_mut() { st.show_only = next; } Ok(json!({ "shownAlone": solo })) } /// Pixels of a fill or smart-object layer's content alone (no mask, effects or opacity). fn content_pixels(doc: &Document, l: &Layer) -> photocraft_raster::Surface { let mut tmp = l.clone(); tmp.mask = None; tmp.vector_mask = None; tmp.effects.items.clear(); tmp.opacity = 1.0; tmp.fill_opacity = 1.0; tmp.blend = photocraft_color::BlendMode::Normal; tmp.clipped = false; tmp.visible = true; let canvas = doc.bounds(); let buf = photocraft_compose::render_layer(&tmp, canvas); let fmt = doc.pixel_format(); let n = fmt.channels(); let mut data = vec![0.0f32; buf.px.len() * n]; for (p, out) in buf.px.iter().zip(data.chunks_exact_mut(n)) { from_rgba_into(&fmt, *p, out); } let mut s = photocraft_raster::Surface::new(fmt); s.write_region(canvas, &data); s.prune(); s } pub(crate) fn kind_of(s: &Session, id: LayerId) -> Option<&'static str> { let d = s.active()?; Some(match &d.doc.layer(id)?.content { LayerContent::Text(_) => "type", LayerContent::Shape(_) => "shape", LayerContent::Fill(_) => "fill", LayerContent::Smart(_) => "smart", _ => return None, }) } /// Rasterize one layer; `only` restricts it to one kind. Without `only` (Rasterize › Layer / All /// Layers: "all vector data") a vector mask becomes a pixel mask too; layer effects stay live, as /// in Photoshop (Rasterize › Layer Style bakes them). Returns false when there was nothing to do. pub(crate) fn rasterize_one(s: &mut Session, id: LayerId, only: Option<&str>, key: &str) -> Result { let vector_mask = only.is_none() && s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| l.vector_mask.is_some()); let content = rasterize_content(s, id, only, key)?; if vector_mask { s.execute("layer.rasterize.vectorMask", json!({"layer": id.0, "coalesce": key}))?; } Ok(content || vector_mask) } fn rasterize_content(s: &mut Session, id: LayerId, only: Option<&str>, key: &str) -> Result { let Some(kind) = kind_of(s, id).filter(|k| only.is_none_or(|o| o == *k)) else { return Ok(false) }; match kind { "type" => { s.execute("type.rasterize", json!({"layer": id.0, "coalesce": key}))?; } "shape" => { s.execute("layer.rasterize.shape", json!({"layer": id.0, "coalesce": key}))?; } "smart" => { s.coalesce_request = Some(key.to_string()); let r = s.edit("Rasterize Smart Object", |doc, _| { let snapshot = doc.clone(); let mut snap = doc.layer(id).cloned().ok_or(EngineError::NoLayer(id))?; if matches!(snap.content, LayerContent::Smart(ref sm) if sm.cache.is_none()) { let _ = crate::smart_cmds::refresh_layer(&snapshot, &mut snap); } let fmt = snapshot.pixel_format(); let surface = match &mut snap.content { LayerContent::Smart(sm) => match sm.cache.take() { Some(c) => c, None => content_pixels(&snapshot, &snap), }, _ => return Err(EngineError::Other(format!("layer {} is not a smart object", id.0))), }; let surface = if surface.format() == fmt { surface } else { surface.convert(fmt) }; let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(surface); l.psd_blocks.retain(|(k, _)| k != b"SoLd" && k != b"SoLE" && k != b"PlLd"); Ok(()) }); s.coalesce_request = None; r?; } _ => { s.coalesce_request = Some(key.to_string()); let r = s.edit("Rasterize Fill Content", |doc, _| { let snap = doc.layer(id).cloned().ok_or(EngineError::NoLayer(id))?; let px = content_pixels(doc, &snap); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(px); l.fill_cache = None; // The PSD fill or placed-layer blocks no longer describe the layer: kept, they // would reopen it as a fill layer or smart object and drop these pixels. l.psd_blocks.retain(|(k, _)| !matches!(k, b"SoCo" | b"GdFl" | b"PtFl" | b"SoLd" | b"PlLd" | b"SoLE")); Ok(()) }); s.coalesce_request = None; r?; } } Ok(true) } /// Layer › Rasterize › Layer / Type / Shape / Fill Content / Smart Object. With a `layer` param, /// just that layer. Otherwise, as in Photoshop ("select the layer or layers you'd like to /// rasterize"), every selected layer the command applies to, in one history step. pub(crate) fn rasterize(s: &mut Session, p: &Value, only: Option<&'static str>) -> Result { let ids = crate::layer_multi_cmds::targets(s, p)?; let many = ids.len() > 1; let key = step_key(s, "rasterize"); let mut done = Vec::new(); for id in ids { if rasterize_one(s, id, only, &key)? { done.push(id.0); } } let Some(&first) = done.first() else { return Err(EngineError::Other(match (only, many) { (Some(k), false) => format!("the layer is not a {k} layer"), (Some(k), true) => format!("none of the selected layers is a {k} layer"), (None, false) => "the layer has nothing to rasterize".into(), (None, true) => "none of the selected layers has anything to rasterize".into(), })); }; let active = s.active().and_then(|d| d.active_layer).map(|id| id.0).filter(|id| done.contains(id)); Ok(json!({"layer": active.unwrap_or(first), "layers": done})) } pub(crate) fn rasterize_all(s: &mut Session) -> Result { let ids: Vec = s.active().map(|d| d.doc.walk().into_iter().map(|(_, _, l)| l.id).collect()).unwrap_or_default(); let key = step_key(s, "rasterizeAll"); let mut n = 0; for id in ids { // Every sub-step shares one history entry via the coalescing key. if rasterize_one(s, id, None, &key)? { n += 1; } } Ok(json!({"rasterized": n})) } // ---------- registry ---------- pub fn specs() -> Vec { macro_rules! spec { ($id:expr, $label:expr, $menu:expr, $sc:expr, $params:expr, $enabled:expr, $run:expr) => { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } vec![ spec!( "edit.stroke", "Stroke…", &["Edit"], None, r##"{"width":1..250=1,"color":"#rrggbb|[r,g,b,a]"=foreground,"location":"inside|center|outside"="center","mode":"any layer blend mode"="normal","opacity":0..100=100,"preserveTransparency":bool=false}"##, has_pixels, stroke ), spec!("edit.transform.rotate180", "Rotate 180°", &["Edit", "Transform"], None, TRANSFORM_TARGET, has_layer, |s, p| transform_preset( s, p, "rotate180" )), spec!("edit.transform.rotate90Cw", "Rotate 90° Clockwise", &["Edit", "Transform"], None, TRANSFORM_TARGET, has_layer, |s, p| transform_preset( s, p, "rotate90Cw" )), spec!("edit.transform.rotate90Ccw", "Rotate 90° Counter Clockwise", &["Edit", "Transform"], None, TRANSFORM_TARGET, has_layer, |s, p| { transform_preset(s, p, "rotate90Ccw") }), spec!("edit.transform.flipHorizontal", "Flip Horizontal", &["Edit", "Transform"], None, TRANSFORM_TARGET, has_layer, |s, p| transform_preset( s, p, "flipHorizontal" )), spec!("edit.transform.flipVertical", "Flip Vertical", &["Edit", "Transform"], None, TRANSFORM_TARGET, has_layer, |s, p| transform_preset( s, p, "flipVertical" )), spec!( "edit.pasteSpecial.pasteInto", "Paste Into", &["Edit", "Paste Special"], Some("Cmd+Alt+Shift+V"), concat!(r#"{"center":[x,y]?,"#, crate::edit_cmds::paste_target!(), "}"), has_clip_and_selection, |s, p| paste_into(s, p, false) ), spec!( "edit.pasteSpecial.pasteOutside", "Paste Outside", &["Edit", "Paste Special"], None, concat!(r#"{"center":[x,y]?,"#, crate::edit_cmds::paste_target!(), "}"), has_clip_and_selection, |s, p| paste_into(s, p, true) ), spec!("select.reselect", "Reselect", &["Select"], Some("Cmd+Shift+D"), "{}", can_reselect, |s, _| { let m = reselect_target(s).ok_or(EngineError::Other("there is no selection to restore".into()))?; s.edit("Reselect", |doc, _| { doc.selection = Some(m); Ok(()) })?; Ok(json!({"selected": true})) }), spec!("image.adjustments.equalize", "Equalize", &["Image", "Adjustments"], None, "{}", has_pixels, equalize), spec!("image.revealAll", "Reveal All", &["Image"], None, "{}", has_doc, |s, _| { let d = s.active().ok_or(EngineError::NoDocument)?; let all = reveal_all_bounds(&d.doc); if all == d.doc.bounds() { return Ok(json!({"changed": false})); } // One History step named as in Photoshop, not the crop's "Crop". let p = json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false}); crate::analysis_cmds::compound(s, "Reveal All", |s| s.execute("image.crop", p))?; Ok(json!({"changed": true})) }), spec!( "layer.new.layerFromBackground", "Layer From Background…", &["Layer", "New"], None, r##"{"ifPresent":bool=false (true: a document without a Background is left as it is, as a Photoshop action's step needs on an image that opened as a normal layer),"select":bool=true (false: the targeted layer stays targeted)}"##, has_background, layer_from_background ), spec!("layer.layerStyle.copyLayerStyle", "Copy Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?}"##, has_layer, |s, p| { let id = layer_param(s, p)?; let d = s.active().ok_or(EngineError::NoDocument)?; let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; let style = crate::CopiedStyle { effects: l.effects.clone(), blend: l.blend, fill: l.fill_opacity, advanced: l.advanced, blend_if: l.blend_if.clone(), excluded_channels: l.excluded_channels, mode: d.doc.mode, }; s.style_clipboard = Some(style); Ok(Value::Null) }), spec!( "layer.layerStyle.pasteLayerStyle", "Paste Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?} (no layer: every selected layer but the Background)"##, |s| { has_layer(s)?; s.style_clipboard.as_ref().map(|_| ()).ok_or_else(|| "no layer style has been copied".into()) }, |s, p| { let ids = style_targets(s, p, |_| true)?; let style = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; let mut fx = style.effects; s.edit("Paste Layer Style", |doc, _| { // A style copied from a document in another mode takes this one's colours. let mode = doc.mode; fx.items = std::mem::take(&mut fx.items).into_iter().map(|e| e.in_mode(mode)).collect(); // Blend If's Gray range carries over to any mode; its channel ranges and the // channel restrictions only to the mode they were set in. let same_mode = style.mode == mode; let blend_if = if same_mode { style.blend_if.clone() } else { // Through `set`, which keeps the normalised form (no trailing full // entries), so a full Gray range pastes as the default. let mut gray = photocraft_doc::BlendIf::default(); gray.set(0, style.blend_if.get(0)); gray }; for id in ids { background_to_layer(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.effects = fx.clone(); // Pass Through is a group's mode: a group's style pastes onto other layers as // Normal, as a merged group's Pass Through becomes Normal. l.blend = if style.blend == photocraft_color::BlendMode::PassThrough && !l.is_group() { photocraft_color::BlendMode::Normal } else { style.blend }; l.fill_opacity = style.fill; l.advanced = style.advanced; l.blend_if = blend_if.clone(); l.excluded_channels = if same_mode { style.excluded_channels } else { 0 }; } Ok(()) })?; Ok(Value::Null) } ), spec!("layer.layerStyle.hideAllEffects", "Hide All Effects", &["Layer", "Layer Style"], None, "{}", has_doc, |s, _| set_all_effects(s, false)), spec!("layer.layerStyle.showAllEffects", "Show All Effects", &[], None, "{}", has_doc, |s, _| set_all_effects(s, true)), spec!( "layer.layerMask.enabled", "Disable Layer Mask", &["Layer", "Layer Mask"], None, r##"{"layer":id?,"enabled":bool? (default: toggle)}"##, has_mask, |s, p| toggle_mask(s, p, "enabled") ), spec!( "layer.layerMask.linked", "Unlink Layer Mask", &["Layer", "Layer Mask"], None, r##"{"layer":id?,"linked":bool? (default: toggle)}"##, has_mask, |s, p| toggle_mask(s, p, "linked") ), spec!( "layer.rasterize.layer", "Layer", &["Layer", "Rasterize"], None, r##"{"layer":id?} (no layer: every selected layer it applies to)"##, has_layer, |s, p| rasterize(s, p, None) ), spec!("layer.rasterize.allLayers", "All Layers", &["Layer", "Rasterize"], None, "{}", has_doc, |s, _| rasterize_all(s)), spec!( "layer.rasterize.type", "Type", &["Layer", "Rasterize"], None, r##"{"layer":id?} (no layer: every selected layer it applies to)"##, has_layer, |s, p| rasterize(s, p, Some("type")) ), spec!( "layer.rasterize.fillContent", "Fill Content", &["Layer", "Rasterize"], None, r##"{"layer":id?} (no layer: every selected layer it applies to)"##, has_layer, |s, p| rasterize(s, p, Some("fill")) ), spec!( "layer.rasterize.smartObject", "Smart Object", &["Layer", "Rasterize"], None, r##"{"layer":id?} (no layer: every selected layer it applies to)"##, has_layer, |s, p| rasterize(s, p, Some("smart")) ), spec!("layer.delete.hiddenLayers", "Hidden Layers", &["Layer", "Delete"], None, "{}", has_doc, |s, _| delete_where(s, "Delete Hidden Layers", |l| !l .visible)), spec!("file.scripts.deleteAllEmptyLayers", "Delete All Empty Layers", &["File", "Scripts"], None, "{}", has_doc, |s, _| delete_where( s, "Delete All Empty Layers", is_empty_layer )), spec!( "layer.ungroupLayers", "Ungroup Layers", &["Layer"], Some("Cmd+Shift+G"), r##"{"layer":id?}"##, |s| match active_layer(s)?.is_group() { true => Ok(()), false => Err("the active layer is not a group".into()), }, ungroup ), spec!( "layer.hideLayers", "Hide Layers", &["Layer"], Some("Cmd+,"), r##"{"layer":id?} (shows them instead when every targeted layer is already hidden, so ⌘, toggles)"##, has_layer, hide_or_show ), spec!("layer.showLayers", "Show Layers", &[], None, r##"{"layer":id?}"##, has_layer, |s, p| set_visible(s, p, true)), spec!( "layer.toggleLockTransparency", "Lock Transparent Pixels", &[], Some("/"), r##"{"layer":id?} (toggles; several layers selected: locks them all unless every one is locked)"##, has_layer, toggle_lock_transparency ), spec!( "layer.showOnly", "Show Only This Layer", &[], None, r##"{"layer":id?} → {shownAlone} (⌥-click a layer's eye; again restores the other layers' visibility)"##, has_layer, show_only ), spec!("filter.blur.average", "Average", &["Filter", "Blur"], None, "{}", has_pixels, average), ] } #[cfg(test)] mod tests { use super::*; fn session(depth: u32) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); s.execute("layer.new.layer", json!({})).unwrap(); s } fn doc(s: &Session) -> &Document { &s.active().unwrap().doc } #[test] fn equalize_average_and_clouds_edit_the_targeted_channel() { // #935: these four ran through `map_pixels`, which always rewrote the active layer, so // with an alpha channel or the Quick Mask targeted they changed the layer and left the // channel alone. let cmds = ["image.adjustments.equalize", "filter.blur.average", "filter.render.clouds", "filter.render.differenceClouds"]; for depth in [8, 16, 32] { for target in ["quickMask", "alpha", "mask"] { for cmd in cmds { let mut s = session(depth); s.tools.foreground = [0.0, 0.0, 0.0, 1.0]; s.tools.background = [1.0, 1.0, 1.0, 1.0]; s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap(); let p = match target { "quickMask" => { s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); json!({"seed": 1}) } "alpha" => { s.execute("channel.new", json!({"fill": "white"})).unwrap(); s.execute("channel.target", json!({"channel": 0})).unwrap(); json!({"seed": 1}) } _ => { s.execute("layer.layerMask.revealAll", json!({})).unwrap(); json!({"seed": 1, "target": "mask"}) } }; // Two levels in the target, so Equalize and Average have something to change. let edit_target = json!({ "target": if target == "alpha" { json!({"channel": 0}) } else { json!(target) } }); s.edit("levels", |doc, active| { let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &edit_target).unwrap(); surf.fill_rect(Rect::new(0, 0, 20, 30), &[0.4]); surf.fill_rect(Rect::new(20, 0, 40, 30), &[0.5]); Ok(()) }) .unwrap(); let full = Rect::new(0, 0, 40, 30); let read = |s: &Session| { let d = doc(s); let layer = d.layer(s.active().unwrap().active_layer.unwrap()).unwrap(); let t = match target { "quickMask" => d.quick_mask.as_ref().unwrap().surface.read_region(full), "alpha" => d.channels[0].surface.read_region(full), _ => layer.mask.as_ref().unwrap().surface.read_region(full), }; (layer.surface().unwrap().read_region(full), t) }; let ((layer0, target0), past) = (read(&s), s.active().unwrap().history.past_len()); s.execute(cmd, p).unwrap(); let (layer1, target1) = read(&s); let case = format!("{cmd} on {target} at {depth}"); assert_eq!(layer1, layer0, "{case}: the layer is untouched"); assert_ne!(target1, target0, "{case}: the target changed"); assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{case}"); s.undo(); assert_eq!(read(&s).1, target0, "{case}: undo restores the target"); } } } } fn active(s: &Session) -> &Layer { let d = s.active().unwrap(); d.doc.layer(d.active_layer.unwrap()).unwrap() } fn pixel(s: &Session, x: i32, y: i32) -> Vec { active(s).surface().unwrap().pixel(x, y) } fn paint_square(s: &mut Session, r: Rect, c: [f32; 4]) { s.edit("paint", |doc, a| { doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(r, &c); Ok(()) }) .unwrap(); } #[test] fn stroke_locations_at_several_depths() { for depth in [8, 16, 32] { for (loc, inside, outside) in [("inside", 1.0, 0.0), ("outside", 0.0, 1.0), ("center", 1.0, 1.0)] { let mut s = session(depth); s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); s.execute("edit.stroke", json!({"width": 2, "color": "#ff0000", "location": loc})).unwrap(); assert!((pixel(&s, 10, 15)[3] - inside).abs() < 0.02, "{depth} {loc} inside {:?}", pixel(&s, 10, 15)); assert!((pixel(&s, 9, 15)[3] - outside).abs() < 0.02, "{depth} {loc} outside {:?}", pixel(&s, 9, 15)); assert_eq!(pixel(&s, 15, 15)[3], 0.0, "centre untouched"); } } // Without a selection the layer's opaque edge is stroked. let mut s = session(8); paint_square(&mut s, Rect::new(10, 10, 20, 20), [0.0, 0.0, 1.0, 1.0]); s.execute("edit.stroke", json!({"width": 3, "color": "#00ff00", "location": "outside"})).unwrap(); assert_eq!(pixel(&s, 8, 15), vec![0.0, 1.0, 0.0, 1.0]); assert_eq!(pixel(&s, 15, 15), vec![0.0, 0.0, 1.0, 1.0]); } /// Photoshop's Edit › Stroke on a selection (checked by hand): Center puts ceil(w/2) px inside /// and floor(w/2) outside (1 px is all inside), fractional widths round up, and every stroked /// pixel is fully opaque, on every edge, horizontal ones included. Inside and Outside are /// w px on their side. #[test] fn stroke_widths_are_whole_opaque_pixels_like_photoshop() { // Selection x 10..30, y 10..30; the edge pixels inside are 10 and 29 on each axis. let band = |loc: &str, w: i32| -> (i32, i32) { match loc { "inside" => (w, 0), "outside" => (0, w), _ => ((w + 1) / 2, w / 2), } }; let mut wrong = Vec::new(); for depth in [8, 16, 32] { for loc in ["center", "inside", "outside"] { for (param, w) in [(json!(1), 1), (json!(2), 2), (json!(3), 3), (json!(4), 4), (json!(5), 5), (json!(0.5), 1), (json!(2.5), 3)] { let mut s = Session::new(); s.execute("file.new", json!({"width": 50, "height": 50, "depth": depth})).unwrap(); s.execute("layer.new.layer", json!({})).unwrap(); s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); s.execute("edit.stroke", json!({"width": param, "color": "#ff0000", "location": loc})).unwrap(); let (inside, outside) = band(loc, w); // (edge pixel inside, step inward) for left, right, top and bottom; the other // coordinate is the middle of the edge. for (name, first_in, inward, horizontal) in [("left", 10, 1, false), ("right", 29, -1, false), ("top", 10, 1, true), ("bottom", 29, -1, true)] { for k in -7..7 { // k ≥ 0: k px inside from the edge; k < 0: -k px outside. let c = first_in + k * inward; let (x, y) = if horizontal { (20, c) } else { (c, 20) }; let want = if (k >= 0 && k < inside) || (k < 0 && -k <= outside) { 1.0 } else { 0.0 }; let a = pixel(&s, x, y)[3]; if (a - want).abs() > 0.004 { wrong.push(format!("{depth}-bit {loc} width {param} {name} edge, {k:+} px: alpha {a}, want {want}")); } } } } } } assert!(wrong.is_empty(), "{} pixels differ from Photoshop:\n{}", wrong.len(), wrong.iter().take(40).cloned().collect::>().join("\n")); } /// Photoshop's 6 px Outside stroke of a 14 px circle selection made without anti-aliasing, /// read pixel by pixel from Photoshop (`#` stroked, `.` not; the hole is the selection). A /// hard selection gives a hard stroke: a pixel is stroked when its centre is less than /// width + 1 from a selected pixel's centre (no partial pixels on the curve). const PS_CIRCLE_OUTSIDE_6: [&str; 31] = [ "..................................", "..................................", "...........############...........", "..........##############..........", ".........################.........", "........##################........", ".......####################.......", "......######################......", ".....#########......#########.....", "....#########........#########....", "....########..........########....", "....#######............#######....", "....######..............######....", "....######..............######....", "....######..............######....", "....######..............######....", "....######..............######....", "....######..............######....", "....#######............#######....", "....########..........########....", "....#########........#########....", ".....#########......#########.....", "......######################......", ".......####################.......", "........##################........", ".........################.........", "..........##############..........", "...........############...........", "..................................", "..................................", "..................................", ]; /// Photoshop's 3 px Inside stroke of the same 14 px hard circle (the selection is the ring /// plus its hole). Inside, a pixel is stroked when its centre is less than width + ½ from an /// unselected pixel's: that offset is the one fitting this, the 5 px and the 6 px samples /// (any value from +0.41 to +0.60), so 6 px fills this circle (its deepest pixel is 6.4 px in). const PS_CIRCLE_INSIDE_3: [&str; 17] = [ "...................", "......######.......", ".....########......", "....##########.....", "...####....####....", "..####......####...", "..###........###...", "..###........###...", "..###........###...", "..###........###...", "..####......####...", "...####....####....", "....##########.....", ".....########......", "......######.......", "...................", "...................", ]; /// The selection of a Photoshop sample grid: an Outside stroke's hole, or an Inside stroke's /// ring and hole. fn grid_selection(rows: &[&str], outside: bool) -> Vec { let (w, h) = (rows[0].len(), rows.len()); let mut mask = vec![0.0f32; w * h]; for (y, row) in rows.iter().enumerate() { let b = row.as_bytes(); if let (Some(first), Some(last)) = (b.iter().position(|c| *c == b'#'), b.iter().rposition(|c| *c == b'#')) { for x in first..=last { if !outside || b[x] == b'.' { mask[y * w + x] = 1.0; } } } } mask } /// Strokes `mask` as the selection of a `w` × `h` layer and returns its alpha per pixel. fn stroke_alpha(mask: &[f32], w: usize, h: usize, depth: u32, params: Value) -> Vec { let mut s = Session::new(); s.execute("file.new", json!({"width": w, "height": h, "depth": depth})).unwrap(); s.execute("layer.new.layer", json!({})).unwrap(); s.edit("select", |doc, _| { doc.selection = Some(sel::mask_to_surface(mask, doc.bounds())); Ok(()) }) .unwrap(); s.execute("edit.stroke", params).unwrap(); (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).map(|(x, y)| pixel(&s, x as i32, y as i32)[3]).collect() } #[test] fn a_hard_circle_strokes_inside_like_photoshop() { let rows = PS_CIRCLE_INSIDE_3; let (w, h) = (rows[0].len(), rows.len()); let mask = grid_selection(&rows, false); for depth in [8, 16, 32] { let got = stroke_alpha(&mask, w, h, depth, json!({"width": 3, "color": "#000000", "location": "inside"})); let wrong: Vec = rows .iter() .enumerate() .flat_map(|(y, row)| row.bytes().enumerate().map(move |(x, c)| (x, y, c))) .filter_map(|(x, y, c)| { let want = if c == b'#' { 1.0 } else { 0.0 }; let a = got[y * w + x]; ((a - want).abs() > 0.004).then(|| format!("({x}, {y}): {a}, Photoshop {want}")) }) .collect(); assert!(wrong.is_empty(), "{depth}-bit: {} pixels differ from Photoshop: {wrong:?}", wrong.len()); // Checked in Photoshop: a 5 px Inside stroke leaves a plus-shaped hole of 12 pixels in // the middle (rows 2, 4, 4, 2 wide), and 6 px fills the whole circle. let hole = [(8, 6), (9, 6), (7, 7), (8, 7), (9, 7), (10, 7), (7, 8), (8, 8), (9, 8), (10, 8), (8, 9), (9, 9)]; let five = stroke_alpha(&mask, w, h, depth, json!({"width": 5, "color": "#000000", "location": "inside"})); for (i, m) in mask.iter().enumerate() { let stroked = *m > 0.0 && !hole.contains(&(i % w, i / w)); assert_eq!(five[i] > 0.996, stroked, "{depth}-bit 5 px Inside at ({}, {})", i % w, i / w); } let full = stroke_alpha(&mask, w, h, depth, json!({"width": 6, "color": "#000000", "location": "inside"})); for (i, m) in mask.iter().enumerate() { assert_eq!(full[i] > 0.996, *m > 0.0, "{depth}-bit 6 px Inside at ({}, {})", i % w, i / w); } } } #[test] fn a_hard_circle_strokes_like_photoshop() { let rows = PS_CIRCLE_OUTSIDE_6; let (w, h) = (rows[0].len(), rows.len()); // The selection: the `.` cells enclosed by the ring on each row. let mask = grid_selection(&rows, true); for depth in [8, 16, 32] { let mut s = Session::new(); s.execute("file.new", json!({"width": w, "height": h, "depth": depth})).unwrap(); s.execute("layer.new.layer", json!({})).unwrap(); s.edit("select", |doc, _| { doc.selection = Some(sel::mask_to_surface(&mask, doc.bounds())); Ok(()) }) .unwrap(); s.execute("edit.stroke", json!({"width": 6, "color": "#000000", "location": "outside"})).unwrap(); let mut wrong = Vec::new(); for (y, row) in rows.iter().enumerate() { for (x, c) in row.bytes().enumerate() { let want = if c == b'#' { 1.0 } else { 0.0 }; let a = pixel(&s, x as i32, y as i32)[3]; if (a - want).abs() > 0.004 { wrong.push(format!("({x}, {y}): {a}, Photoshop {want}")); } } } assert!(wrong.is_empty(), "{depth}-bit: {} pixels differ from Photoshop: {wrong:?}", wrong.len()); } } /// Select All, then Stroke › Inside draws a border around the canvas (a common Photoshop /// technique): the canvas edge is a selection edge like any other, on all four sides. #[test] fn stroking_a_selection_at_the_canvas_edge_draws_that_edge() { for depth in [8, 16, 32] { for (loc, w, inside) in [("inside", 3, 3), ("center", 3, 2), ("center", 1, 1)] { let mut s = session(depth); s.execute("select.all", json!({})).unwrap(); s.execute("edit.stroke", json!({"width": w, "color": "#ff0000", "location": loc})).unwrap(); // 40 × 30 canvas: k px in from each side, in the middle of the side. for name in ["left", "right", "top", "bottom"] { for k in 0..6 { let (x, y) = match name { "left" => (k, 15), "right" => (39 - k, 15), "top" => (20, k), _ => (20, 29 - k), }; let want = if k < inside { 1.0 } else { 0.0 }; let a = pixel(&s, x, y)[3]; assert!((a - want).abs() < 0.004, "{depth}-bit {loc} {w} px, {name} side, {k} px in: alpha {a}, want {want}"); } } } } } #[test] fn stroke_uses_fill_blending_and_preserves_transparency_at_every_depth() { for depth in [8, 16, 32] { let mut s = session(depth); paint_square(&mut s, Rect::new(10, 10, 20, 20), [0.5, 0.5, 0.5, 1.0]); s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); let past = s.active().unwrap().history.past_len(); s.execute( "edit.stroke", json!({"width": 2, "color": "#ffffff", "location": "inside", "mode": "multiply", "opacity": 100, "preserveTransparency": true}), ) .unwrap(); assert_eq!(s.active().unwrap().history.past_len(), past + 1, "Stroke is one history step"); let edge = pixel(&s, 10, 15); assert!((edge[0] - 0.5).abs() < 0.015, "{depth}-bit Multiply must preserve gray: {edge:?}"); assert_eq!(edge[3], 1.0); s.execute("edit.stroke", json!({"width": 2, "color": "#ffffff", "location": "outside", "preserveTransparency": true})).unwrap(); assert_eq!(pixel(&s, 9, 15)[3], 0.0, "{depth}-bit transparency lock prevents new outer pixels"); s.execute("edit.stroke", json!({"width": 2, "color": "#ffffff", "location": "outside", "opacity": 50})).unwrap(); let out = pixel(&s, 9, 15); assert!((out[3] - 0.5).abs() < 0.015, "{depth}-bit 50% stroke opacity: {out:?}"); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(pixel(&s, 9, 15)[3], 0.0); } } #[test] fn stroke_rejects_invalid_blending_options_without_modifying_the_document() { let mut s = session(8); s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); let past = s.active().unwrap().history.past_len(); for params in [ json!({"mode": "unknown-mode"}), json!({"mode": 42}), json!({"mode": "passThrough"}), json!({"opacity": "lots"}), json!({"preserveTransparency": "true"}), ] { assert!(s.execute("edit.stroke", params.clone()).is_err(), "{params}"); assert_eq!(s.active().unwrap().history.past_len(), past, "{params} should not create a history step"); } } #[test] fn transform_presets_are_exact() { let mut s = session(8); paint_square(&mut s, Rect::new(10, 10, 20, 14), [1.0, 0.0, 0.0, 1.0]); paint_square(&mut s, Rect::new(10, 10, 11, 11), [0.0, 1.0, 0.0, 1.0]); s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14)); assert_eq!(pixel(&s, 19, 10), vec![0.0, 1.0, 0.0, 1.0]); s.execute("edit.transform.flipVertical", json!({})).unwrap(); assert_eq!(pixel(&s, 19, 13), vec![0.0, 1.0, 0.0, 1.0]); s.execute("edit.transform.rotate180", json!({})).unwrap(); assert_eq!(pixel(&s, 10, 10), vec![0.0, 1.0, 0.0, 1.0]); s.execute("edit.transform.rotate90Cw", json!({})).unwrap(); let b = active(&s).surface().unwrap().content_bounds(); assert_eq!((b.width(), b.height()), (4, 10)); // Top-left corner goes to the top-right after a clockwise turn. assert_eq!(pixel(&s, b.x1 - 1, b.y0), vec![0.0, 1.0, 0.0, 1.0]); s.execute("edit.transform.rotate90Ccw", json!({})).unwrap(); assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14)); assert_eq!(pixel(&s, 10, 10), vec![0.0, 1.0, 0.0, 1.0]); } /// #6928: with a selection, the presets flip and turn about the selection's own bounds, as /// in Photoshop: Select All mirrors across the canvas, a marquee across its own centre. /// Without a selection they keep turning about the content (above). #[test] fn transform_presets_turn_about_the_selection() { let mut s = session(8); paint_square(&mut s, Rect::new(10, 10, 20, 14), [1.0, 0.0, 0.0, 1.0]); paint_square(&mut s, Rect::new(10, 10, 11, 11), [0.0, 1.0, 0.0, 1.0]); let green = vec![0.0, 1.0, 0.0, 1.0]; // Select All on the 40 × 30 canvas: across the canvas's centre lines. s.execute("select.all", json!({})).unwrap(); let past = s.active().unwrap().history.past_len(); s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(20, 10, 30, 14)); assert_eq!(pixel(&s, 29, 10), green); assert_eq!(s.active().unwrap().history.past_len(), past + 1, "one undo step"); s.execute("edit.transform.flipVertical", json!({})).unwrap(); assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(20, 16, 30, 20)); assert_eq!(pixel(&s, 29, 19), green); s.execute("edit.transform.rotate180", json!({})).unwrap(); assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14)); assert_eq!(pixel(&s, 10, 10), green); // A marquee (0, 0)–(24, 30) holding the whole square: across x = 12. s.execute("select.rect", json!({"x": 0, "y": 0, "width": 24, "height": 30})).unwrap(); s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(4, 10, 14, 14)); assert_eq!(pixel(&s, 13, 10), green); // Undo puts it back where it was. s.undo(); assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14)); // A targeted alpha channel turns about the selection's bounds too. s.execute("channel.new", json!({})).unwrap(); s.edit("paint channel", |doc, _| { doc.channels[0].surface.fill_rect(Rect::new(10, 10, 20, 20), &[1.0]); Ok(()) }) .unwrap(); s.execute("channel.target", json!({"channel": 0})).unwrap(); s.execute("select.all", json!({})).unwrap(); s.execute("edit.transform.flipHorizontal", json!({})).unwrap(); assert_eq!(s.active().unwrap().doc.channels[0].surface.content_bounds(), Rect::new(20, 10, 30, 20)); } #[test] fn reselect_restores_the_last_selection() { let mut s = session(8); assert!(!s.is_enabled("select.reselect")); s.execute("select.rect", json!({"x": 2, "y": 3, "width": 5, "height": 6})).unwrap(); s.execute("select.deselect", json!({})).unwrap(); assert!(doc(&s).selection.is_none()); s.execute("select.reselect", json!({})).unwrap(); assert_eq!(doc(&s).selection.as_ref().unwrap().content_bounds(), Rect::new(2, 3, 7, 9)); assert!(!s.is_enabled("select.reselect"), "nothing to reselect while a selection exists"); } #[test] fn equalize_spreads_values() { for depth in [8, 16] { let mut s = session(depth); paint_square(&mut s, Rect::new(0, 0, 40, 15), [0.4, 0.4, 0.4, 1.0]); paint_square(&mut s, Rect::new(0, 15, 40, 30), [0.5, 0.5, 0.5, 1.0]); s.execute("image.adjustments.equalize", json!({})).unwrap(); let (dark, light) = (pixel(&s, 1, 1)[0], pixel(&s, 1, 20)[0]); assert!(dark < 0.05 && light > 0.95, "{depth}: {dark} {light}"); } } /// A selection masks Clouds but doesn't change their size: inside it they are the canvas's /// clouds, as when Clouds sized itself by the document (before it became a filter). #[test] fn clouds_in_a_selection_are_the_canvas_clouds() { // Big enough that the canvas (cloud size 100 px) and the selection (16 px) size differently. let big = |depth: u32| { let mut s = Session::new(); s.execute("file.new", json!({"width": 400, "height": 300, "depth": depth})).unwrap(); s.execute("layer.new.layer", json!({})).unwrap(); s }; for depth in [8, 16, 32] { let mut whole = big(depth); whole.execute("filter.render.clouds", json!({"seed": 4})).unwrap(); let mut s = big(depth); s.execute("select.rect", json!({"x": 50, "y": 40, "width": 60, "height": 50})).unwrap(); s.execute("filter.render.clouds", json!({"seed": 4})).unwrap(); for (x, y) in [(50, 40), (75, 60), (100, 85), (109, 89)] { let (a, b) = (pixel(&s, x, y), pixel(&whole, x, y)); assert!((0..4).all(|c| (a[c] - b[c]).abs() < 1e-3), "depth {depth} at {x},{y}: {a:?} vs {b:?}"); } assert_eq!(pixel(&s, 300, 200)[3], 0.0, "outside the selection the layer stays empty"); } } #[test] fn average_and_clouds() { let mut s = session(8); paint_square(&mut s, Rect::new(0, 0, 20, 30), [1.0, 0.0, 0.0, 1.0]); paint_square(&mut s, Rect::new(20, 0, 40, 30), [0.0, 0.0, 1.0, 1.0]); s.execute("filter.blur.average", json!({})).unwrap(); let p = pixel(&s, 5, 5); assert!((p[0] - 0.5).abs() < 0.01 && (p[2] - 0.5).abs() < 0.01, "{p:?}"); s.execute("filter.render.clouds", json!({"seed": 3})).unwrap(); let vals: Vec = (0..40).map(|x| pixel(&s, x, 12)[0]).collect(); let (lo, hi) = vals.iter().fold((1.0f32, 0.0f32), |(a, b), v| (a.min(*v), b.max(*v))); assert!(hi - lo > 0.05, "clouds vary: {lo}..{hi}"); assert!(vals.windows(2).all(|w| (w[0] - w[1]).abs() < 0.2), "clouds are smooth"); // Difference Clouds over itself with the same seed cancels to black. s.execute("filter.render.differenceClouds", json!({"seed": 3})).unwrap(); assert!(pixel(&s, 7, 7)[..3].iter().all(|v| *v < 0.01), "{:?}", pixel(&s, 7, 7)); } #[test] fn paste_into_masks_to_the_selection() { let mut s = session(8); paint_square(&mut s, Rect::new(0, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]); s.execute("select.all", json!({})).unwrap(); s.execute("edit.copy", json!({})).unwrap(); let steps = s.active().unwrap().history.past_len(); s.execute("select.rect", json!({"x": 20, "y": 10, "width": 8, "height": 8})).unwrap(); s.execute("edit.pasteSpecial.pasteInto", json!({})).unwrap(); let l = active(&s); let m = l.mask.as_ref().unwrap(); assert!(!m.linked); assert_eq!(m.surface.content_bounds(), Rect::new(20, 10, 28, 18)); assert_eq!(m.value(0, 0), 0.0); assert!(doc(&s).selection.is_none()); assert_eq!(s.active().unwrap().history.past_len(), steps + 2, "select + one Paste Into step"); // Paste Outside reveals everything but the selection. s.execute("select.rect", json!({"x": 20, "y": 10, "width": 8, "height": 8})).unwrap(); s.execute("edit.pasteSpecial.pasteOutside", json!({})).unwrap(); let m = active(&s).mask.as_ref().unwrap(); assert_eq!((m.value(0, 0), m.value(22, 12)), (1.0, 0.0)); } #[test] fn option_click_eye_shows_one_layer_then_restores() { let mut s = session(8); s.execute("layer.new.layer", json!({})).unwrap(); let ids: Vec = doc(&s).layers.iter().map(|l| l.id).collect(); // Background, Layer 1 (hidden beforehand), Layer 2. s.execute("layer.hideLayers", json!({"layer": ids[1].0})).unwrap(); let vis = |s: &Session| doc(s).layers.iter().map(|l| l.visible).collect::>(); assert_eq!(s.execute("layer.showOnly", json!({"layer": ids[1].0})).unwrap()["shownAlone"], true); assert_eq!(vis(&s), [false, true, false]); // Another eye moves the solo; the original snapshot survives. s.execute("layer.showOnly", json!({"layer": ids[2].0})).unwrap(); assert_eq!(vis(&s), [false, false, true]); assert_eq!(s.execute("layer.showOnly", json!({"layer": ids[2].0})).unwrap()["shownAlone"], false); assert_eq!(vis(&s), [true, false, true], "restored, with Layer 1 still hidden"); // One history step each way. s.execute("edit.undo", json!({})).unwrap(); assert_eq!(vis(&s), [false, false, true]); } #[test] fn show_only_keeps_groups_and_their_contents() { let mut s = session(8); s.execute("layer.new.layer", json!({})).unwrap(); let inner = active(&s).id; let group = s.execute("layer.groupLayers", json!({"layer": inner.0})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); let shown = |s: &Session| doc(s).walk().iter().map(|(_, _, l)| l.visible).collect::>(); // Background, Layer 1, Group 1, Layer 2 (inside the group). s.execute("layer.showOnly", json!({"layer": inner.0})).unwrap(); assert_eq!(shown(&s), [false, false, true, true], "the enclosing group stays visible"); // An undo ends the solo, so the next ⌥-click solos again from the current state. s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.execute("layer.showOnly", json!({"layer": inner.0})).unwrap()["shownAlone"], true); s.execute("edit.undo", json!({})).unwrap(); // Showing a group alone leaves its contents as they are. s.execute("layer.setProps", json!({"layer": inner.0, "visible": false})).unwrap(); s.execute("layer.showOnly", json!({"layer": group.0})).unwrap(); assert_eq!(shown(&s), [false, false, true, false]); s.execute("layer.showOnly", json!({"layer": group.0})).unwrap(); assert_eq!(shown(&s), [true, true, true, false]); } #[test] fn show_only_without_a_snapshot_shows_every_layer() { let mut s = session(16); let bg = doc(&s).layers[0].id; s.execute("layer.hideLayers", json!({"layer": bg.0})).unwrap(); // Layer 1 is already the only visible layer: ⌥-clicking it shows everything. assert_eq!(s.execute("layer.showOnly", json!({})).unwrap()["shownAlone"], false); assert!(doc(&s).layers.iter().all(|l| l.visible)); assert!(s.execute("layer.showOnly", json!({"layer": u64::MAX})).is_err()); assert!(Session::new().execute("layer.showOnly", json!({})).is_err()); } #[test] fn a_mask_turns_the_background_into_a_layer() { for (cmd, needs_selection) in [ ("layer.layerMask.revealAll", false), ("layer.layerMask.hideAll", false), ("layer.layerMask.revealSelection", true), ("layer.layerMask.hideSelection", true), ] { let mut s = session(8); let bg = doc(&s).layers[0].id; if needs_selection { s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); } s.execute(cmd, json!({"layer": bg.0})).unwrap(); let l = &doc(&s).layers[0]; assert!(l.mask.is_some() && l.name == "Layer 0" && !l.locks.transparency && !l.locks.position, "{cmd}"); assert!(!s.is_enabled("layer.new.layerFromBackground"), "{cmd}"); // One step: undo brings the locked Background back, without a mask. assert!(s.undo()); let l = &doc(&s).layers[0]; assert!(l.mask.is_none() && l.name == "Background" && l.locks.transparency, "{cmd}"); } // Other layers keep their name and locks. let mut s = session(8); let top = doc(&s).layers[1].id; s.execute("layer.setProps", json!({"layer": top.0, "locks": {"position": true}})).unwrap(); s.execute("layer.layerMask.revealAll", json!({"layer": top.0})).unwrap(); let l = doc(&s).layer(top).unwrap(); assert!(l.mask.is_some() && l.name != "Layer 0" && l.locks.position); assert_eq!(doc(&s).layers[0].name, "Background"); } #[test] fn hide_layers_toggles_once_every_target_is_hidden() { let mut s = session(8); s.execute("layer.new.layer", json!({})).unwrap(); let b = active(&s).id; let visible = |s: &Session| doc(s).layer(b).unwrap().visible; // Select › All Layers leaves out the Background, as in Photoshop. let all = |s: &Session| crate::layer_multi_cmds::selected(s).iter().map(|id| doc(s).layer(*id).unwrap().visible).collect::>(); s.execute("layer.hideLayers", json!({})).unwrap(); assert!(!visible(&s)); s.execute("layer.hideLayers", json!({})).unwrap(); assert!(visible(&s), "⌘, again shows it"); // Several selected: any one still shown means hide them all; then they all come back. s.execute("layer.hideLayers", json!({"layer": b.0})).unwrap(); s.execute("select.allLayers", json!({})).unwrap(); assert!(all(&s).len() > 1); s.execute("layer.hideLayers", json!({})).unwrap(); assert!(all(&s).iter().all(|v| !v)); s.execute("layer.hideLayers", json!({})).unwrap(); assert!(all(&s).iter().all(|v| *v)); } #[test] fn slash_toggles_lock_transparent_pixels_but_not_on_the_background() { let mut s = session(8); let bg = doc(&s).layers[0].id; assert!(doc(&s).is_background(bg)); s.execute("layer.new.layer", json!({})).unwrap(); let top = active(&s).id; let locked = |s: &Session, id: LayerId| doc(s).layer(id).unwrap().locks.transparency; assert_eq!(s.execute("layer.toggleLockTransparency", json!({})).unwrap()["locked"], true); assert!(locked(&s, top)); s.execute("layer.toggleLockTransparency", json!({})).unwrap(); assert!(!locked(&s, top)); s.undo(); assert!(locked(&s, top), "one undoable step each"); let bg_locks = doc(&s).layer(bg).unwrap().locks; assert!(s.execute("layer.toggleLockTransparency", json!({"layer": bg.0})).is_err()); assert_eq!(doc(&s).layer(bg).unwrap().locks, bg_locks, "the Background is left alone"); assert!(s.execute("layer.toggleLockTransparency", json!({"layer": u64::MAX})).is_err()); } #[test] fn layer_utilities() { let mut s = session(8); // Layer from Background. assert!(s.is_enabled("layer.new.layerFromBackground")); s.execute("layer.new.layerFromBackground", json!({})).unwrap(); assert_eq!(doc(&s).layers[0].name, "Layer 0"); assert!(!doc(&s).layers[0].locks.transparency); assert!(!s.is_enabled("layer.new.layerFromBackground")); // Copy / paste style and hide all effects. s.execute("layer.layerStyle.dropShadow", json!({})).unwrap(); s.execute("layer.layerStyle.copyLayerStyle", json!({})).unwrap(); let top = doc(&s).layers[1].id; s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": top.0})).unwrap(); assert_eq!(doc(&s).layers[1].effects.items.len(), 1); s.execute("layer.layerStyle.hideAllEffects", json!({})).unwrap(); assert!(doc(&s).walk().iter().all(|(_, _, l)| l.effects.items.is_empty() || !l.effects.enabled)); // Mask toggles. s.execute("layer.layerMask.revealAll", json!({})).unwrap(); s.execute("layer.layerMask.enabled", json!({})).unwrap(); assert!(!active(&s).mask.as_ref().unwrap().enabled); s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap(); assert!(!active(&s).mask.as_ref().unwrap().linked); // Hide, delete hidden, delete empty. s.execute("layer.new.layer", json!({})).unwrap(); s.execute("layer.hideLayers", json!({})).unwrap(); assert!(!active(&s).visible); assert_eq!(s.execute("layer.delete.hiddenLayers", json!({})).unwrap()["deleted"], 1); s.execute("layer.new.layer", json!({})).unwrap(); let n = doc(&s).layer_count(); assert_eq!(s.execute("file.scripts.deleteAllEmptyLayers", json!({})).unwrap()["deleted"], 1); assert_eq!(doc(&s).layer_count(), n - 1); } #[test] fn ungroup_restores_children_in_order() { let mut s = session(8); let a = active(&s).id; s.execute("layer.groupLayers", json!({})).unwrap(); assert!(active(&s).is_group()); s.execute("layer.ungroupLayers", json!({})).unwrap(); assert_eq!(s.active().unwrap().active_layer, Some(a)); assert!(doc(&s).layers.iter().all(|l| !l.is_group())); assert_eq!(doc(&s).layers.len(), 2); } #[test] fn reveal_all_grows_the_canvas() { let mut s = session(8); s.execute("layer.translate", json!({"dx": 0, "dy": 0})).ok(); paint_square(&mut s, Rect::new(-5, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]); assert_eq!(reveal_all_bounds(doc(&s)), Rect::new(-5, 0, 40, 30)); let steps = s.active().unwrap().history.past_len(); s.execute("image.revealAll", json!({})).unwrap(); assert_eq!((doc(&s).size.width, doc(&s).size.height), (45, 30)); let history = &s.active().unwrap().history; assert_eq!(history.past_len(), steps + 1, "one step"); assert_eq!(history.entries().last().map(String::as_str), Some("Reveal All"), "named as in Photoshop, not Crop"); assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], false); } #[test] fn rasterize_fill_and_all_in_one_step() { let mut s = session(8); s.execute("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).unwrap(); s.execute("layer.newFillLayer.solidColor", json!({"color": "#0000ff"})).unwrap(); s.execute("layer.rasterize.fillContent", json!({})).unwrap(); assert!(matches!(active(&s).content, LayerContent::Raster(_))); assert_eq!(pixel(&s, 3, 3), vec![0.0, 0.0, 1.0, 1.0]); assert!(s.execute("layer.rasterize.type", json!({})).is_err()); let steps = s.active().unwrap().history.past_len(); assert_eq!(s.execute("layer.rasterize.allLayers", json!({})).unwrap()["rasterized"], 1); assert_eq!(s.active().unwrap().history.past_len(), steps + 1); assert!(doc(&s).walk().iter().all(|(_, _, l)| !matches!(l.content, LayerContent::Fill(_)))); } #[test] fn rasterize_smart_object_layer() { let mut s = session(8); s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); let px_before = pixel(&s, 5, 5); s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); assert!(matches!(active(&s).content, LayerContent::Smart(_))); s.execute("layer.rasterize.smartObject", json!({})).unwrap(); assert!(matches!(active(&s).content, LayerContent::Raster(_))); let px_after = pixel(&s, 5, 5); assert_eq!(px_after, px_before); // Case 2: New layer above background let mut s2 = session(8); s2.execute("layer.new.layer", json!({})).unwrap(); s2.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); s2.execute("edit.fill", json!({"contents": "color", "color": "#00ff00"})).unwrap(); s2.execute("select.deselect", json!({})).unwrap(); let px_mid = pixel(&s2, 20, 20); assert_eq!(px_mid[1], 1.0); // green s2.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); assert!(matches!(active(&s2).content, LayerContent::Smart(_))); s2.execute("layer.rasterize.smartObject", json!({})).unwrap(); assert!(matches!(active(&s2).content, LayerContent::Raster(_))); let px_after_rast = pixel(&s2, 20, 20); assert_eq!(px_after_rast, px_mid); // Case 3: Shape layer converted to smart object then rasterized let mut s3 = session(8); s3.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 40], "fill": "#0000ff"})).unwrap(); let px_shape = pixel(&s3, 20, 20); s3.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); assert!(matches!(active(&s3).content, LayerContent::Smart(_))); s3.execute("layer.rasterize.layer", json!({})).unwrap(); assert!(matches!(active(&s3).content, LayerContent::Raster(_))); let px_after_shape = pixel(&s3, 20, 20); assert_eq!(px_after_shape, px_shape); // Case 4: Transformed smart object let mut s4 = session(8); s4.execute("layer.new.layer", json!({})).unwrap(); s4.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); s4.execute("edit.fill", json!({"contents": "color", "color": "#ffff00"})).unwrap(); s4.execute("select.deselect", json!({})).unwrap(); s4.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); // Move smart object by 10, 10 s4.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 10, 10]})).unwrap(); s4.execute("layer.rasterize.smartObject", json!({})).unwrap(); assert!(matches!(active(&s4).content, LayerContent::Raster(_))); assert_eq!(pixel(&s4, 30, 30), vec![1.0, 1.0, 0.0, 1.0]); } }