Files
photocraft/crates/engine/src/edit_cmds.rs
T

2384 lines
123 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Everyday Photoshop commands: clipboard (Cut/Copy/Copy Merged/Paste/Paste in Place), Layer via
//! Copy/Cut, Merge Visible, Auto Tone/Contrast/Color, Toggle Last State and Transform Again.
use photocraft_color::{ColorMode, PixelFormat};
use photocraft_doc::adjust::LevelsChannel;
use photocraft_doc::{Adjustment, Document, Layer, LayerContent, LayerId};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
/// The paste commands' `"target"` param (for `concat!` into their params docs).
macro_rules! paste_target {
() => {
r#""target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (not pixels: the luminosity pasted into that mask or channel)"#
};
}
pub(crate) use paste_target;
/// Pixels on the session clipboard, with where they came from (for Paste in Place).
#[derive(Clone, Debug)]
pub struct Clip {
pub surface: Surface,
pub bounds: Rect,
/// The copied layer itself, when Copy took a whole layer that Paste should recreate rather
/// than rasterize (see [`whole_layer`]): a scratch document in the source's colour holding it.
/// `surface` still holds its pixels, for Paste Into, masks, channels and other apps.
pub layers: Option<Box<Document>>,
/// The source document's resolution (ppi): File › New from the clipboard takes it, as
/// Photoshop's Clipboard preset does. None for an image from another app.
pub resolution: Option<f32>,
}
/// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer
/// whole when it is a smart object (#2428), a type or shape layer, or a group (its whole subtree)
/// (#2670), so Paste recreates it instead of its pixels. Fill and adjustment layers have no
/// pixels of their own, so they are taken whole too rather than refused (#2344). Pixel layers
/// still copy their pixels; several selected layers are [`multi_copy_ids`].
fn takes_whole(d: &crate::DocState, id: LayerId) -> bool {
d.doc.selection.is_none()
&& d.selected_layers().len() <= 1
&& crate::layer_copy_cmds::layered(d.doc.mode)
&& d.doc.layer(id).is_some_and(|l| !matches!(l.content, LayerContent::Raster(_)))
}
/// A scratch document in the source's colour, to hold copied layers.
fn scratch_doc(doc: &Document) -> Document {
let mut copies = Document::new("", doc.size, doc.mode, doc.depth);
copies.icc_profile = doc.icc_profile.clone();
copies.resolution_dpi = doc.resolution_dpi;
copies.patterns = doc.patterns.clone();
copies
}
/// Scope linked records to the copied layer tree: a PSD block can also hold files belonging
/// to unselected or hidden layers, which must not follow this copy into a native save.
fn copy_linked_sources(source: &Document, copies: &mut Document) -> Result<()> {
fn collect<'a>(layers: &'a [Layer], depth: usize, paths: &mut std::collections::HashSet<&'a str>) -> Result<()> {
if depth > photocraft_doc::MAX_GROUP_DEPTH {
return Err(EngineError::Other("can't copy linked smart objects from layers nested too deeply".into()));
}
for layer in layers {
if let LayerContent::Smart(sm) = &layer.content
&& let photocraft_doc::SmartSource::Linked { path } = &sm.source
{
paths.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to copy smart-object source IDs".into()))?;
paths.insert(path);
}
if let Some(children) = layer.children().filter(|children| !children.is_empty()) {
collect(children, depth + 1, paths)?;
}
}
Ok(())
}
let mut paths = std::collections::HashSet::new();
collect(&copies.layers, 0, &mut paths)?;
if paths.is_empty() {
return Ok(());
}
for (signature, key, data) in &source.metadata.psd_global_blocks {
if !matches!(key, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE") {
continue;
}
let selected = photocraft_io::linked::select_items(data, &|uuid, _| paths.contains(uuid))
.map_err(|e| EngineError::Other(format!("Could not copy smart-object contents: {e}; the clipboard was not changed")))?;
let selected = selected.map(std::sync::Arc::new).unwrap_or_else(|| data.clone());
if !selected.is_empty() {
copies.metadata.psd_global_blocks.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to copy smart-object source records".into()))?;
copies.metadata.psd_global_blocks.push((*signature, *key, selected));
}
}
Ok(())
}
/// The layer Copy takes whole ([`takes_whole`]): a scratch document in the source's colour
/// holding it.
fn whole_layer(d: &crate::DocState, id: LayerId) -> Option<Box<Document>> {
let l = d.doc.layer(id).filter(|_| takes_whole(d, id))?;
let mut copies = scratch_doc(&d.doc);
copies.layers.push(l.clone());
Some(Box::new(copies))
}
/// The layers Copy takes when several are selected and nothing is selected in the pixels
/// (Photoshop, #2478): the selected layers that no selected group already carries, bottom-to-top.
/// Empty otherwise, and for a single layer ([`takes_whole`] decides that case).
fn multi_copy_ids(d: &crate::DocState) -> Vec<LayerId> {
if d.doc.selection.is_some() || !crate::layer_copy_cmds::layered(d.doc.mode) {
return Vec::new();
}
let selected = d.selected_layers();
if selected.len() < 2 {
return Vec::new();
}
crate::layer_multi_cmds::top_level(&d.doc, &selected)
}
/// Every selected layer, whole ([`multi_copy_ids`]), in a scratch document: Paste recreates them
/// all, each with its mask, styles and content. The Background becomes an ordinary layer, as in
/// Photoshop's Duplicate Layer.
fn whole_layers(d: &crate::DocState, ids: &[LayerId]) -> Option<Box<Document>> {
let mut copies = scratch_doc(&d.doc);
for id in ids {
let mut l = d.doc.layer(*id)?.clone();
if d.doc.is_background(*id) {
l.name = "Layer 0".into();
l.locks = Default::default();
}
copies.layers.push(l);
}
Some(Box::new(copies))
}
fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
/// Paste and New from Clipboard need only a clipboard: with no document open, they make one.
fn has_clip_only(s: &Session) -> std::result::Result<(), String> {
s.clipboard.as_ref().map(|_| ()).ok_or_else(|| "the clipboard is empty".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())
}
/// Cut and Layer via Cut edit the pixels, so they need a pixel layer.
fn has_pixels(s: &Session) -> std::result::Result<(), String> {
match active_layer(s)?.content {
LayerContent::Raster(_) => Ok(()),
LayerContent::Smart(_) => Err("the smart object is not directly editable".into()),
_ => Err("the active layer has no pixels".into()),
}
}
/// Copy only reads: any layer that shows pixels will do, so a smart object, type or shape layer
/// copies what it shows (Photoshop).
fn has_layer_pixels(s: &Session) -> std::result::Result<(), String> {
active_layer(s)?.surface().map(|_| ()).ok_or_else(|| "the active layer has no pixels".into())
}
/// Copy: a targeted alpha channel (#2307), a layer with pixels, or one taken whole (a group with
/// no selection, #2670).
fn can_copy(s: &Session) -> std::result::Result<(), String> {
if crate::channel_clip::copies_alpha_channel(s) || s.active().is_some_and(|d| !multi_copy_ids(d).is_empty()) {
return Ok(());
}
has_layer_pixels(s).or_else(|e| s.active().and_then(|d| d.active_layer.filter(|id| takes_whole(d, *id))).map(|_| ()).ok_or(e))
}
fn has_clip(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
s.clipboard.as_ref().map(|_| ()).ok_or_else(|| "the clipboard is empty".into())
}
fn active_id(s: &Session) -> Result<LayerId> {
s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))
}
/// The pixels of `src` inside the selection (or all of them), as a straight-alpha surface.
///
/// Built tile by tile (#4516). A source tile that the copy takes whole (inside the canvas, fully
/// selected or no selection, already in the clip's format) is shared copy-on-write, so a plain
/// Copy or Select All, Copy costs a pointer per tile; only the other tiles are read and written,
/// one at a time, instead of the whole area as one `f32` buffer.
fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result<Clip> {
let fmt = src.format();
let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true);
let area = match sel {
Some(m) => m.content_bounds().intersect(&canvas),
None => src.content_bounds().intersect(&canvas),
};
let mut out = Surface::new(with_alpha);
if area.is_empty() {
return Ok(Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None });
}
// Only the selected area is read: converting the whole layer (a 24 MP Background gaining
// alpha) cost about a second per Layer via Copy (#668).
let mut shared = Vec::new();
for tc in area.tiles() {
let tr = tc.rect().intersect(&area);
if fmt.alpha
&& tr == tc.rect()
&& let Some(tile) = src.tile(tc)
&& sel.is_none_or(|m| crate::fill_cmds::Mask::of(m, tc).full(tr))
{
// Reading and writing it back would give the same bytes.
shared.push((tc, tile.clone()));
continue;
}
let px = lift_region(src, sel, tr, with_alpha.channels())?;
out.try_write_region(tr, &px).map_err(|e| EngineError::Other(format!("not enough memory while copying pixels ({e}); the document was not changed")))?;
}
out.put_tiles(shared);
out.prune();
let bounds = out.content_bounds();
Ok(Clip { surface: out, bounds, layers: None, resolution: None })
}
/// [`lift`] over one rectangle: `src`'s pixels with alpha (`n` channels), the selection
/// multiplied into it.
fn lift_region(src: &Surface, sel: Option<&Surface>, r: Rect, n: usize) -> Result<Vec<f32>> {
let fmt = src.format();
let mut px = if fmt.alpha {
crate::allocation::read_region(src, r, "copying pixels")?
} else {
let opaque = crate::allocation::read_region(src, r, "copying pixels")?;
let k = fmt.channels();
let len = opaque
.len()
.checked_div(k.max(1))
.and_then(|pixels| pixels.checked_mul(n))
.ok_or_else(|| EngineError::Other("not enough memory for copying pixels (requested size overflowed); the document was not changed".into()))?;
let mut v = crate::allocation::filled(len, 0.0, "copying pixels")?;
let mut at = 0;
for p in opaque.chunks_exact(k.max(1)) {
let Some(dst) = v.get_mut(at..at + k.max(1)) else { break };
dst.copy_from_slice(p);
at += k.max(1);
if let Some(alpha) = v.get_mut(at) {
*alpha = 1.0;
}
at += 1;
}
v
};
if let Some(m) = sel {
let mask = crate::allocation::read_region(m, r, "applying the selection to copied pixels")?;
let mk = m.format().channels().max(1);
for (p, a) in px.chunks_exact_mut(n.max(1)).zip(mask.chunks_exact(mk)) {
if let Some(alpha) = p.last_mut() {
*alpha *= a[0];
}
}
}
Ok(px)
}
/// Merged composite of the visible document as a surface in the document's format.
fn merged_surface(doc: &Document) -> Surface {
let fmt = doc.pixel_format();
photocraft_compose::flatten_to_surface(doc, PixelFormat::new(fmt.mode, fmt.sample, true), None)
}
fn copy(s: &mut Session, merged: bool) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let canvas = d.doc.bounds();
let clip = if merged {
lift(&merged_surface(&d.doc), d.doc.selection.as_ref(), canvas)?
} else {
let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?;
let several = multi_copy_ids(d);
let mut layers = if several.is_empty() { whole_layer(d, id) } else { whole_layers(d, &several) };
if let Some(copies) = &mut layers {
copy_linked_sources(&d.doc, copies)?;
}
match (d.doc.layer(id).and_then(Layer::surface).filter(|_| several.is_empty()), &layers) {
(Some(surf), _) => Clip { layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? },
// A group or fill layer has no pixels of its own: the clipboard pixels are what it
// shows. Adjustment layers show nothing by themselves, so their clip spans the canvas
// they apply to: Paste places them as a full-canvas layer, and the UI still mirrors
// the clip to the OS clipboard (an empty clip would let a stale OS image replace it).
(None, Some(copies)) => {
let mut shown = (**copies).clone();
for l in &mut shown.layers {
l.visible = true;
}
let clip = lift(&merged_surface(&shown), None, canvas)?;
let bounds = if clip.bounds.is_empty() { canvas } else { clip.bounds };
Clip { layers, bounds, ..clip }
}
(None, None) => return Err(EngineError::Other("the active layer has no pixels".into())),
}
};
if clip.bounds.is_empty() {
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
}
let b = clip.bounds;
let clip = Clip { resolution: Some(d.doc.resolution_dpi), ..clip };
s.clipboard = Some(clip);
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()]}))
}
/// Copy with the layer mask targeted (`"target":"mask"`, #2928): the mask's values as a
/// grayscale clip, inside the selection or the whole canvas — an untouched mask copies at its
/// default value, so a plain Reveal All / Hide All mask transfers too. Paste then writes into
/// another layer's mask (targeted, #1035), in this or another document.
fn copy_mask(s: &mut Session) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let canvas = d.doc.bounds();
let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let mask = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?.mask.as_ref().ok_or_else(|| EngineError::Other("the active layer has no layer mask".into()))?;
let sel = d.doc.selection.as_ref();
let area = sel.map_or(canvas, |m| m.content_bounds().intersect(&canvas));
if area.is_empty() {
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
}
let n = mask.surface.format().channels().max(1);
let values = crate::allocation::read_region(&mask.surface, area, "copying the mask")?;
let len = values
.len()
.checked_div(n)
.and_then(|pixels| pixels.checked_mul(2))
.ok_or_else(|| EngineError::Other("not enough memory for copying the mask (requested size overflowed); the document was not changed".into()))?;
let cover = sel.map(|m| (m.read_region(area), m.format().channels().max(1)));
let mut px = crate::allocation::filled(len, 0.0, "copying the mask")?;
for (i, p) in values.chunks_exact(n).enumerate() {
px[i * 2] = p.first().copied().unwrap_or(0.0);
px[i * 2 + 1] = cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0));
}
let fmt = PixelFormat::new(ColorMode::Grayscale, mask.surface.format().sample, true);
let mut surface = Surface::new(fmt);
surface
.try_write_region(area, &px)
.map_err(|e| EngineError::Other(format!("not enough memory while copying the mask ({e}); the document was not changed")))?;
surface.prune();
let bounds = surface.content_bounds();
if bounds.is_empty() {
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
}
let b = bounds;
let resolution = Some(d.doc.resolution_dpi);
s.clipboard = Some(Clip { surface, bounds: b, layers: None, resolution });
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()], "target": "mask"}))
}
fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Result<()> {
let sel = doc.selection.clone();
let canvas = doc.bounds();
let area = sel.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas));
clear_area(doc, id, area, sel.as_ref(), background)
}
/// Cut / Clear on a layer: makes the selected pixels transparent. The Background can't hold
/// transparency, so there the area is filled with the background colour instead (Photoshop).
pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Option<&Surface>, background: [f32; 4]) -> Result<()> {
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let bg = doc.is_background(id);
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
if bg {
crate::pixels::try_fill_surface(surf, area, background, sel, true)?;
} else {
crate::pixels::try_clear_surface(surf, area, sel)?;
}
surf.prune();
Ok(())
}
/// Paste as a new layer (or into the targeted mask or channel: [`paste_to_target`]). `in_place` keeps the original position; otherwise the pixels are centred
/// on `center` (the view centre from the UI) or the canvas, unless they already overlap the canvas.
fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result<Value> {
crate::allocation::checkpoint("pasting pixels")?;
let clip = s.clipboard.clone().ok_or(EngineError::Other("the clipboard is empty".into()))?;
let Some(d) = s.active() else {
// Nothing open to paste into: the clipboard becomes a document of its own (#368).
return new_from_clipboard(s);
};
if crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels {
return paste_to_target(s, p, in_place, None, "Paste");
}
// A targeted colour channel takes the paste instead of a new layer (#2307).
if let Some(k) = crate::channel_clip::paste_color_target(s, p) {
return crate::channel_clip::paste(s, p, k, in_place, None, "Paste");
}
let canvas = d.doc.bounds();
let fmt = d.doc.pixel_format();
let (dx, dy) = paste_offset(&clip, canvas, p, in_place);
if let Some(layers) = clip.layers.as_deref().filter(|_| crate::layer_copy_cmds::layered(d.doc.mode)) {
return paste_layers(s, layers, dx, dy);
}
let moved = shifted(&clip.surface, dx, dy);
let target = PixelFormat::new(fmt.mode, fmt.sample, true);
let surf = if moved.format() == target { moved } else { moved.convert(target) };
let id = s.edit("Paste", |doc, active| {
let mut l = Layer::raster(doc.next_layer_name("Layer"), target);
*crate::pixels_mut(&mut l)? = surf;
let id = doc.insert_above(*active, l);
*active = Some(id);
doc.selection = None;
Ok(id)
})?;
// `edit` reports only where the pasted pixels land as damage, not the whole document (#2901).
Ok(json!({"layer": id.0, "offset": [dx, dy]}))
}
/// Paste of a whole copied layer ([`whole_layer`]): fresh copies (a group's children too),
/// converted to the document's colour, moved as the pixels would be, above the active layer. A pasted smart object gets its
/// own contents, independent of the copied one (as across documents).
fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let mut copies = layers.clone();
for l in &mut copies.layers {
*l = l.duplicate();
}
crate::layer_copy_cmds::adapt_copies(s, &mut copies, &d.doc)?;
crate::layer_copy_cmds::translate_copies(&mut copies, dx, dy);
let patterns: Vec<_> = copies.patterns.iter().filter(|pat| !d.doc.patterns.iter().any(|q| q.id == pat.id)).cloned().collect();
// A copied smart object may name a PSD-embedded linked file; land its `lnk` block too (#3584).
let mut linked = Vec::new();
for (signature, key, data) in copies.metadata.psd_global_blocks.iter().filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE")) {
if d.doc.metadata.psd_global_blocks.iter().any(|(_, dk, dd)| dk == key && std::sync::Arc::ptr_eq(dd, data)) {
continue;
}
// The clipboard may contain a subset of a destination block. Compare complete records,
// so copying one layer in the same document does not duplicate large embedded files.
let missing = photocraft_io::linked::select_items(data, &|_, raw| {
!d.doc.metadata.psd_global_blocks.iter().any(|(_, dk, dd)| dk == key && photocraft_io::linked::contains_item(dd, raw))
})
.map_err(|e| EngineError::Other(format!("Could not paste smart-object contents: {e}; the document was not changed")))?;
let missing = missing.map(std::sync::Arc::new).unwrap_or_else(|| data.clone());
if !missing.is_empty() {
linked.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to paste smart-object source records".into()))?;
linked.push((*signature, *key, missing));
}
}
let id = s.edit("Paste", |doc, active| {
doc.patterns.extend(patterns);
doc.metadata.psd_global_blocks.extend(linked);
for l in copies.layers {
*active = Some(doc.insert_above(*active, l));
}
doc.selection = None;
active.ok_or(EngineError::Other("nothing to paste".into()))
})?;
Ok(json!({"layer": id.0, "offset": [dx, dy]}))
}
/// How far a paste moves the clipboard pixels: not at all in place (or when they lie on the
/// canvas and no `center` is given), else onto `center` (the view centre from the UI) or the
/// canvas centre.
pub(crate) fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) {
if in_place || (clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) {
return (0, 0);
}
let c = p.get("center").and_then(Value::as_array).filter(|a| a.len() >= 2).map(|a| (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0)));
let (cx, cy) = c.unwrap_or(((f64::from(canvas.x0) + f64::from(canvas.x1)) / 2.0, (f64::from(canvas.y0) + f64::from(canvas.y1)) / 2.0));
let b = clip.bounds;
((cx - (f64::from(b.x0) + f64::from(b.x1)) / 2.0).round() as i32, (cy - (f64::from(b.y0) + f64::from(b.y1)) / 2.0).round() as i32)
}
pub(crate) fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface {
if dx == 0 && dy == 0 { surface.clone() } else { photocraft_algo::resample::translate_surface(surface, dx, dy) }
}
/// Paste into the targeted layer mask, alpha channel or Quick Mask (`"target"`) instead of as a
/// new layer (Photoshop, #1035): the pasted pixels' luminosity, placed as a paste places them,
/// over what was there. Their transparency, and `limit` (Paste Into / Outside), let it show
/// through. One history step.
pub(crate) fn paste_to_target(s: &mut Session, p: &Value, in_place: bool, limit: Option<&Surface>, label: &str) -> Result<Value> {
crate::allocation::checkpoint("pasting pixels")?;
let clip = s.clipboard.clone().ok_or(EngineError::Other("the clipboard is empty".into()))?;
let d = s.active().ok_or(EngineError::NoDocument)?;
let canvas = d.doc.bounds();
let layer = d.active_layer;
let (dx, dy) = paste_offset(&clip, canvas, p, in_place);
let src = shifted(&clip.surface, dx, dy);
let area = src.content_bounds().intersect(&canvas);
s.edit(label, |doc, _| {
let (surf, _) = crate::channel_cmds::target_surface(doc, layer, p)?;
crate::fill_cmds::composite_over(surf, &src, area, limit)?;
surf.prune();
doc.selection = None;
Ok(())
})?;
Ok(json!({"target": p.get("target"), "offset": [dx, dy]}))
}
/// A new document the size of the clipboard image, holding it as its one layer, in the pixel
/// format it was copied in (#368).
fn new_from_clipboard(s: &mut Session) -> Result<Value> {
let clip = s.clipboard.clone().ok_or(EngineError::Other("the clipboard is empty".into()))?;
let b = clip.bounds;
let (w, h) = (b.width(), b.height());
if w == 0 || h == 0 {
return Err(EngineError::Other("the clipboard image has no size".into()));
}
let (Some(dx), Some(dy)) = (b.x0.checked_neg(), b.y0.checked_neg()) else {
return Err(EngineError::Other("the clipboard origin is outside the coordinate range for a new document".into()));
};
let fmt = clip.surface.format();
let target = PixelFormat::new(fmt.mode, fmt.sample, true);
let moved = if dx == 0 && dy == 0 { clip.surface } else { photocraft_algo::resample::translate_surface(&clip.surface, dx, dy) };
let mut doc = Document::new("Untitled", photocraft_geom::Size::new(w, h), fmt.mode, fmt.sample);
if let Some(r) = clip.resolution.filter(|r| r.is_finite() && *r > 0.0) {
doc.resolution_dpi = r;
}
let mut l = Layer::raster(doc.next_layer_name("Layer"), target);
*crate::pixels_mut(&mut l)? = if moved.format() == target { moved } else { moved.convert(target) };
doc.layers.push(l);
let i = s.add_document(doc, None);
// Clipboard content has never been saved; closing this tab must ask before discarding it.
if let Some(st) = s.active_mut() {
st.saved_revision = 0;
}
Ok(json!({"document": i, "width": w, "height": h}))
}
fn layer_via(s: &mut Session, cut: bool) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let id = active_id(s)?;
let has_sel = d.doc.selection.is_some();
if !has_sel && !cut && crate::layer_multi_cmds::multi(s, &Value::Null) {
// No selection, several layers selected: copy every one of them (Photoshop, #2777).
return crate::layer_multi_cmds::duplicate_selected(s, true, "Layer Via Copy");
}
if !has_sel && !cut {
// No selection: Layer via Copy duplicates the whole layer (Photoshop).
let nid = s.edit("Layer Via Copy", |doc, active| {
let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let mut dup = src.duplicate();
dup.name = doc.copy_name(&src.name);
dup.locks = Default::default();
let nid = doc.insert_above(Some(id), dup);
*active = Some(nid);
Ok(nid)
})?;
return Ok(json!({"layer": nid.0}));
}
let surf = d.doc.layer(id).and_then(Layer::surface).ok_or(EngineError::Other("the active layer has no pixels".into()))?;
let clip = lift(surf, d.doc.selection.as_ref(), d.doc.bounds())?;
if clip.bounds.is_empty() {
return Err(EngineError::Other("Could not complete the command: the selected area is empty".into()));
}
let bg = s.tools.background;
let label = if cut { "Layer Via Cut" } else { "Layer Via Copy" };
let nid = s.edit(label, |doc, active| {
if cut {
clear_selected(doc, id, bg)?;
}
let mut l = Layer::raster(doc.next_layer_name("Layer"), clip.surface.format());
*crate::pixels_mut(&mut l)? = clip.surface;
let nid = doc.insert_above(Some(id), l);
*active = Some(nid);
// Photoshop deselects: a following Free Transform moves the whole new layer (#1096).
doc.selection = None;
Ok(nid)
})?;
Ok(json!({"layer": nid.0}))
}
fn merge_visible(s: &mut Session) -> Result<Value> {
s.edit("Merge Visible", |doc, active| {
let visible: Vec<usize> = doc.layers.iter().enumerate().filter(|(_, l)| l.visible).map(|(i, _)| i).collect();
if visible.len() < 2 && !visible.iter().any(|i| doc.layers[*i].is_group()) {
return Err(EngineError::Other("there are not enough visible layers to merge".into()));
}
let lowest = visible[0];
let base = doc.layers[lowest].clone();
let is_background = lowest == 0 && doc.is_background(base.id);
// Composite of visible layers only (hidden ones stay where they are).
let mut solo = doc.clone();
solo.layers.retain(|l| l.visible);
let fmt = doc.pixel_format();
let fmt = if is_background { fmt } else { PixelFormat::new(fmt.mode, fmt.sample, true) };
let mut merged = Layer::raster(base.name.clone(), fmt);
merged.locks = base.locks;
*crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers(&solo, fmt, is_background.then_some([1.0, 1.0, 1.0]));
let mid = merged.id;
let mut out = Vec::with_capacity(doc.layers.len());
for (i, l) in doc.layers.drain(..).enumerate() {
if i == lowest {
out.push(merged.clone());
} else if !l.visible {
out.push(l);
}
}
doc.layers = out;
*active = Some(mid);
Ok(())
})?;
Ok(Value::Null)
}
/// Histogram of a pixel layer (8-bit bins per channel: R, G, B) inside the selection.
fn histograms(surf: &Surface, area: Rect) -> [[u64; 256]; 3] {
let mut h = [[0u64; 256]; 3];
let step = ((area.width() as u64 * area.height() as u64) / 4_000_000).max(1) as usize; // sample big images
let mut row = vec![[0u8; 4]; area.width() as usize];
for y in (area.y0..area.y1).step_by(step.isqrt().max(1)) {
surf.read_rgba8_into(Rect::new(area.x0, y, area.x1, y + 1), &mut row);
for p in row.iter().step_by(step.isqrt().max(1)) {
if p[3] == 0 {
continue;
}
for c in 0..3 {
h[c][p[c] as usize] += 1;
}
}
}
h
}
/// Black and white points with `clip` (fraction) clipped at each end.
fn clip_points(h: &[u64; 256], clip: f64) -> (f32, f32) {
let total: u64 = h.iter().sum();
if total == 0 {
return (0.0, 1.0);
}
let lim = (total as f64 * clip) as u64;
let (mut acc, mut lo) = (0u64, 0usize);
while lo < 254 && acc + h[lo] <= lim {
acc += h[lo];
lo += 1;
}
let (mut acc, mut hi) = (0u64, 255usize);
while hi > lo + 1 && acc + h[hi] <= lim {
acc += h[hi];
hi -= 1;
}
(lo as f32 / 255.0, hi as f32 / 255.0)
}
fn median(h: &[u64; 256]) -> f32 {
let total: u64 = h.iter().sum();
let mut acc = 0;
for (i, v) in h.iter().enumerate() {
acc += v;
if acc * 2 >= total {
return i as f32 / 255.0;
}
}
0.5
}
/// Image › Auto Tone / Auto Contrast / Auto Color (0.1% clipping, Photoshop's defaults).
fn auto_adjust(s: &mut Session, kind: &str) -> Result<Value> {
let id = active_id(s)?;
let d = s.active().ok_or(EngineError::NoDocument)?;
let surf = d.doc.layer(id).and_then(Layer::surface).ok_or(EngineError::Other("the active layer has no pixels".into()))?;
let area = d.doc.selection.as_ref().map_or(d.doc.bounds(), |m| m.content_bounds()).intersect(&d.doc.bounds());
let h = histograms(surf, area);
let clip = 0.001;
let mut per = [LevelsChannel::default(), LevelsChannel::default(), LevelsChannel::default()];
match kind {
"contrast" => {
// Same points for all channels (preserves colour), from the combined histogram.
let mut all = [0u64; 256];
for c in &h {
for (a, v) in all.iter_mut().zip(c) {
*a += v;
}
}
let (lo, hi) = clip_points(&all, clip);
for p in &mut per {
(p.in_black, p.in_white) = (lo, hi);
}
}
_ => {
for (c, p) in per.iter_mut().enumerate() {
(p.in_black, p.in_white) = clip_points(&h[c], clip);
}
if kind == "color" {
// Neutralise midtones: move each channel's median onto the average median.
let meds: Vec<f32> = (0..3).map(|c| median(&h[c])).collect();
let target = meds.iter().sum::<f32>() / 3.0;
for (c, p) in per.iter_mut().enumerate() {
let t = ((meds[c] - p.in_black) / (p.in_white - p.in_black).max(1e-3)).clamp(0.02, 0.98);
let want = ((target - p.in_black) / (p.in_white - p.in_black).max(1e-3)).clamp(0.02, 0.98);
// out = t^(1/g) = want → g = ln t / ln want
p.gamma = (t.ln() / want.ln()).clamp(0.1, 9.99);
}
}
}
}
let adj = Adjustment::Levels { master: LevelsChannel::default(), per_channel: per, space: Default::default(), black: LevelsChannel::default() };
let label = match kind {
"contrast" => "Auto Contrast",
"color" => "Auto Color",
_ => "Auto Tone",
};
s.edit(label, |doc, _| {
let sel = doc.selection.clone();
let mode = doc.mode;
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
crate::pixels::adjust_surface(surf, &adj, sel.as_ref(), mode);
Ok(())
})?;
Ok(Value::Null)
}
fn toggle_last_state(s: &mut Session) -> Result<Value> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let redo = st.history.can_redo();
Ok(json!(if redo { s.redo() } else { s.undo() }))
}
/// Edit › Transform › Again: replay the last `edit.transform` on the active layer.
fn transform_again(s: &mut Session) -> Result<Value> {
let p = transform_again_params(s)?;
s.execute("edit.transform", p)
}
/// Transform Again on a copy (⌥⇧⌘T, no menu item): duplicates the active layer and repeats the
/// last transform on the copy, as one history step, so pressing it again steps and repeats
/// (#352). The transform is worked out before anything changes, so a refusal leaves no copy.
fn transform_again_copy(s: &mut Session) -> Result<Value> {
let p = transform_again_params(s)?;
let id = active_id(s)?;
let journal = s.journal.len();
// The steps are recorded (Again on a Copy is not replayable itself): with one layer selected,
// "duplicate the current layer" plays back on any document, a layer id only on this one.
let dup = if crate::layer_multi_cmds::multi(s, &Value::Null) { json!({"layer": id.0}) } else { json!({}) };
s.execute("layer.duplicate", dup)?;
match s.execute("edit.transform", p) {
Ok(r) => {
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
st.history.purge_last();
st.history.set_current_label("Transform Again");
Ok(r)
}
Err(e) => {
// The copy (e.g. of a layer whose position is locked) couldn't be transformed: take
// it back, leaving nothing to redo and no Duplicate step to record.
s.undo();
s.journal.truncate(journal);
if let Some(st) = s.active_mut() {
st.history.clear_redo();
}
Err(e)
}
}
}
/// The `edit.transform` params that repeat the last transform on the active layer.
fn transform_again_params(s: &Session) -> Result<Value> {
let (_, last) =
s.journal.iter().rev().find(|(id, _)| id == "edit.transform").cloned().ok_or(EngineError::Other("there is no transform to repeat".into()))?;
let (rect, quad) = (last.get("rect").cloned(), last.get("quad").cloned());
let mut p = json!({"interpolation": last.get("interpolation").cloned().unwrap_or(json!("bicubic"))});
match (rect, quad, last.get("matrix").cloned()) {
(Some(r), Some(q), _) => {
let v = |x: &Value| -> Vec<f64> { x.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default() };
let r = v(&r);
let q: Vec<Vec<f64>> = q.as_array().map(|a| a.iter().map(v).collect()).unwrap_or_default();
if r.len() != 4 || q.len() != 4 || q.iter().any(|row| row.len() < 2) {
return Err(EngineError::Other("the last transform can't be repeated".into()));
}
let h = photocraft_algo::transform::Homography::rect_to_quad(
[r[0], r[1], r[2], r[3]],
[[q[0][0], q[0][1]], [q[1][0], q[1][1]], [q[2][0], q[2][1]], [q[3][0], q[3][1]]],
)
.ok_or(EngineError::Other("the last transform can't be repeated".into()))?;
let d = s.active().ok_or(EngineError::NoDocument)?;
let l = d.doc.layer(active_id(s)?).ok_or(EngineError::Other("no active layer".into()))?;
let b = crate::transform_cmds::transform_bounds(&d.doc, l);
if b.is_empty() {
return Err(EngineError::Other("nothing to transform".into()));
}
let nr = [b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64];
let corners = [(nr[0], nr[1]), (nr[2], nr[1]), (nr[2], nr[3]), (nr[0], nr[3])].map(|(x, y)| {
let (a, b) = h.apply(x, y);
[a, b]
});
p["rect"] = json!(nr);
p["quad"] = json!(corners);
}
(_, _, Some(m)) => p["matrix"] = m,
_ => return Err(EngineError::Other("the last transform can't be repeated".into())),
}
Ok(p)
}
/// Guides further than this from the canvas origin are refused: far past any document (300000 px)
/// yet well within f32 precision.
const MAX_GUIDE_PX: f64 = 1_000_000.0;
/// A guide `position` in document pixels: a number of pixels, or text with a unit as in Photoshop's
/// New Guide dialog ("50%", "2.5 cm", "12 pt"). Percent is of the document's width for a vertical
/// guide and its height for a horizontal one; points and picas follow Units & Rulers › Point Size.
/// `None` when there is no `position`.
fn guide_position(s: &Session, p: &Value, vertical: bool) -> Result<Option<f64>> {
let Some(v) = p.get("position") else { return Ok(None) };
let px = match (v.as_f64(), v.as_str()) {
(Some(n), _) => n,
(None, Some(text)) => {
let (n, unit) = crate::prefs::Unit::parse_length(text).ok_or_else(|| {
EngineError::Other(format!("`position` {text:?} is not a length: give a number of pixels, or a number with px, in, cm, mm, pt, pc or %"))
})?;
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let extent = if vertical { doc.size.width } else { doc.size.height };
unit.unwrap_or(crate::prefs::Unit::Pixels).to_px(n, doc.resolution_dpi as f64, extent as f64, s.prefs().units_and_rulers.point_size.per_inch())
}
_ => return Err(EngineError::Other("`position` must be a number of pixels or text with a unit".into())),
};
if !px.is_finite() || px.abs() > MAX_GUIDE_PX {
return Err(EngineError::Other(format!("`position` is out of range (at most {MAX_GUIDE_PX} px from the canvas origin)")));
}
Ok(Some(px))
}
/// Photoshop's guide colours, in its order, by the names `view.newGuide` takes: New Guide's and
/// New Guide Layout's Color lists. Cyan (the default guide colour in Preferences, so a guide
/// dragged from a ruler reads as Cyan), Magenta and Yellow are the values New Guide Layout first
/// shipped with; the rest approximate Photoshop's on screen, rounded to web-safe steps.
pub const GUIDE_COLORS: [(&str, [u8; 3]); 9] = [
("lightBlue", [0x33, 0x66, 0xff]),
("lightRed", [0xff, 0x33, 0x33]),
("green", [0x33, 0xff, 0x33]),
("mediumBlue", [0x33, 0x33, 0xff]),
("yellow", [0xff, 0xcc, 0x00]),
("magenta", [0xff, 0x00, 0xff]),
("cyan", [0x4a, 0xff, 0xff]),
("lightGray", [0xe7, 0xe7, 0xe7]),
("black", [0x00, 0x00, 0x00]),
];
/// A guide `color`: one of [`GUIDE_COLORS`]' names or `"#rrggbb"`. `None` when there is no `color`
/// (the guide uses Preferences › Guides, Grid & Slices › Guide colour).
fn guide_color(p: &Value) -> Result<Option<[u8; 3]>> {
let Some(v) = p.get("color") else { return Ok(None) };
let text = v.as_str().ok_or(EngineError::Other("`color` must be a colour name or \"#rrggbb\"".into()))?;
GUIDE_COLORS.iter().find(|(name, ..)| name.eq_ignore_ascii_case(text)).map(|(.., rgb)| *rgb).or_else(|| crate::prefs::parse_hex(text)).map(Some).ok_or_else(
|| EngineError::Other(format!("`color` {text:?} is not a guide colour: use \"#rrggbb\" or one of {}", GUIDE_COLORS.map(|c| c.0).join(", "))),
)
}
/// Edit Selected Guides (the guide context menu): change one guide's orientation, position and
/// colour in one history step. A guide that keeps its orientation keeps its index; one that turns
/// goes to the end of the other list. Without `color` it keeps its own colour, or none.
fn edit_guide(s: &mut Session, p: &Value) -> Result<Value> {
let orientation = |key: &str| match p.get(key).map(|v| v.as_str()) {
None => Ok(None),
Some(Some("horizontal")) => Ok(Some(false)),
Some(Some("vertical")) => Ok(Some(true)),
Some(_) => Err(EngineError::Other(format!("`{key}` must be \"horizontal\" or \"vertical\""))),
};
let from = orientation("orientation")?.unwrap_or(false);
let to = orientation("newOrientation")?.unwrap_or(from);
let index = p.get("index").and_then(Value::as_u64).ok_or(EngineError::Other("missing `index`".into()))?;
let pos = guide_position(s, p, to)?;
let color = guide_color(p)?;
s.edit("Edit Guide", |doc, _| {
let guides = &mut doc.guides;
let list = if from { &guides.vertical } else { &guides.horizontal };
let i = usize::try_from(index).ok().filter(|i| *i < list.len()).ok_or(EngineError::Other("no such guide".into()))?;
let old = list[i];
let at = pos.unwrap_or(old);
let rgb = color.or_else(|| guides.color(from, old));
if from == to {
let list = if from { &mut guides.vertical } else { &mut guides.horizontal };
list[i] = at;
} else {
(if from { &mut guides.vertical } else { &mut guides.horizontal }).remove(i);
(if to { &mut guides.vertical } else { &mut guides.horizontal }).push(at);
}
guides.prune_colors();
if rgb.is_some() {
guides.set_color(to, at, rgb);
}
Ok(())
})?;
Ok(Value::Null)
}
/// View › New Guide / guide moves (undoable, like Photoshop's "New Guide"/"Move Guide" states).
fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
let vertical = p.get("orientation").and_then(Value::as_str).unwrap_or("horizontal") == "vertical";
let pos = guide_position(s, p, vertical)?;
let color = if op == "new" { guide_color(p)? } else { None };
let index = p.get("index").and_then(Value::as_u64).map(|v| v as usize);
let label = match op {
"new" => "New Guide",
"move" => "Move Guide",
"delete" => "Delete Guide",
_ => "Clear Guides",
};
s.edit(label, |doc, _| {
let guides = &mut doc.guides;
let g = if vertical { &mut guides.vertical } else { &mut guides.horizontal };
match op {
"new" => {
let at = pos.ok_or(EngineError::Other("missing `position`".into()))?;
g.push(at);
if color.is_some() {
guides.set_color(vertical, at, color);
}
}
"move" => {
let i = index.filter(|i| *i < g.len()).ok_or(EngineError::Other("no such guide".into()))?;
let to = pos.ok_or(EngineError::Other("missing `position`".into()))?;
let from = std::mem::replace(&mut g[i], to);
// The guide takes its colour along; another guide left at the old position keeps it.
if let Some(rgb) = guides.color(vertical, from) {
guides.set_color(vertical, to, Some(rgb));
}
guides.prune_colors();
}
"delete" => {
let i = index.filter(|i| *i < g.len()).ok_or(EngineError::Other("no such guide".into()))?;
g.remove(i);
guides.prune_colors();
}
_ => {
guides.horizontal.clear();
guides.vertical.clear();
guides.colors.clear();
}
}
Ok(())
})?;
Ok(Value::Null)
}
/// Farthest the ruler zero point may sit from the image, in document pixels.
const MAX_RULER_ORIGIN: f64 = 1.0e7;
/// Move the rulers' zero point (`view.setRulerOrigin`), or put it back at the image's top-left
/// (`view.resetRulerOrigin`, `p` = None). Undoable like the guides.
fn ruler_origin_cmd(s: &mut Session, p: Option<&Value>) -> Result<Value> {
let origin = match p {
None => [0.0, 0.0],
Some(p) => {
let axis = |k: &str| {
p.get(k)
.and_then(Value::as_f64)
.filter(|v| v.is_finite() && v.abs() <= MAX_RULER_ORIGIN)
.ok_or_else(|| EngineError::Other(format!("`{k}` must be a number of pixels within ±{MAX_RULER_ORIGIN}")))
};
[axis("x")?, axis("y")?]
}
};
let label = if p.is_some() { "Set Ruler Origin" } else { "Reset Ruler Origin" };
s.edit(label, |doc, _| {
doc.ruler_origin = origin;
Ok(())
})?;
Ok(json!({"x": origin[0], "y": origin[1]}))
}
pub fn specs() -> Vec<CommandSpec> {
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.cut", "Cut", &["Edit"], Some("Cmd+X"), "{} (a targeted colour channel cuts that channel only)", has_pixels, |s, p| {
// Refuse a locked layer before copying, so a refused Cut leaves the clipboard alone.
let id = active_id(s)?;
crate::commands::check_pixels_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?;
// A targeted colour channel cuts that channel only (#2698).
if let Some(k) = crate::channel_clip::paste_color_target(s, p) {
return crate::channel_clip::cut(s, id, k);
}
let r = copy(s, false)?;
let bg = s.tools.background;
s.edit("Cut Pixels", |doc, _| clear_selected(doc, id, bg))?;
Ok(r)
}),
spec!(
"edit.copy",
"Copy",
&["Edit"],
Some("Cmd+C"),
r##"{"target":"mask"? = the targeted mask, copied as grayscale (#2928)} (a targeted colour or alpha channel copies that channel, as grayscale)"##,
can_copy,
|s, p| {
if crate::commands::is_mask_target(p) {
return copy_mask(s);
}
match crate::channel_clip::copy(s)? {
Some(r) => Ok(r),
None => copy(s, false),
}
}
),
spec!("edit.copyMerged", "Copy Merged", &["Edit"], Some("Cmd+Shift+C"), "{}", has_doc, |s, _| copy(s, true)),
spec!(
"edit.paste",
"Paste",
&["Edit"],
Some("Cmd+V"),
concat!(
r#"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas),"#,
paste_target!(),
"} (with no document open: a new document from the clipboard)"
),
has_clip_only,
|s, p| paste(s, p, false)
),
spec!(
"file.newFromClipboard",
"New from Clipboard",
&["File"],
None,
"{} (a new document the size of the clipboard image, holding it as one layer)",
has_clip_only,
|s, _| new_from_clipboard(s)
),
spec!(
"edit.pasteSpecial.pasteInPlace",
"Paste in Place",
&["Edit", "Paste Special"],
Some("Cmd+Shift+V"),
concat!("{", paste_target!(), "}"),
has_clip,
|s, p| paste(s, p, true)
),
spec!("layer.new.layerViaCopy", "Layer via Copy", &["Layer", "New"], Some("Cmd+J"), "{}", has_doc, |s, _| layer_via(s, false)),
spec!("layer.new.layerViaCut", "Layer via Cut", &["Layer", "New"], Some("Cmd+Shift+J"), "{}", has_pixels, |s, _| layer_via(s, true)),
spec!("layer.mergeVisible", "Merge Visible", &["Layer"], Some("Cmd+Shift+E"), "{}", has_doc, |s, _| merge_visible(s)),
spec!("image.autoTone", "Auto Tone", &["Image"], Some("Cmd+Shift+L"), "{}", has_pixels, |s, _| auto_adjust(s, "tone")),
spec!("image.autoContrast", "Auto Contrast", &["Image"], Some("Cmd+Alt+Shift+L"), "{}", has_pixels, |s, _| auto_adjust(s, "contrast")),
spec!("image.autoColor", "Auto Color", &["Image"], Some("Cmd+Shift+B"), "{}", has_pixels, |s, _| auto_adjust(s, "color")),
spec!("edit.toggleLastState", "Toggle Last State", &["Edit"], Some("Cmd+Alt+Z"), "{}", has_doc, |s, _| toggle_last_state(s)),
spec!("edit.transform.again", "Again", &["Edit", "Transform"], Some("Cmd+Shift+T"), "{}", has_doc, |s, _| transform_again(s)),
spec!(
"edit.transform.againCopy",
"Transform Again on a Copy",
&[],
Some("Cmd+Alt+Shift+T"),
"{} (duplicates the active layer and repeats the last transform on the copy: step and repeat)",
has_doc,
|s, _| transform_again_copy(s)
),
spec!(
"view.newGuide",
"New Guide…",
&["View"],
None,
r##"{"orientation":"horizontal|vertical","position":px or "<number><unit>","color":"lightBlue|lightRed|green|mediumBlue|yellow|magenta|cyan|lightGray|black" or "#rrggbb"?} (units px, in, cm, mm, pt, pc or %; % is of the width for a vertical guide, the height for a horizontal one; without a color the guide uses the guide colour in Preferences)"##,
has_doc,
|s, p| guide_cmd(s, p, "new")
),
spec!(
"view.moveGuide",
"Move Guide",
&[],
None,
r##"{"orientation":"horizontal|vertical","index":n,"position":px or "<number><unit>"} (units as for view.newGuide)"##,
has_doc,
|s, p| guide_cmd(s, p, "move")
),
spec!(
"view.editGuide",
"Edit Guide",
&[],
None,
r##"{"orientation":"horizontal|vertical","index":n,"newOrientation":"horizontal|vertical"?,"position":px or "<number><unit>"?,"color":name or "#rrggbb"?} (Edit Selected Guides: units and colours as for view.newGuide; % is of the new orientation's extent; whatever is left out stays as it was)"##,
has_doc,
edit_guide
),
spec!("view.deleteGuide", "Delete Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n}"##, has_doc, |s, p| guide_cmd(s, p, "delete")),
spec!("view.clearGuides", "Clear Guides", &["View"], None, "{}", has_doc, |s, p| guide_cmd(s, p, "clear")),
spec!(
"view.setRulerOrigin",
"Set Ruler Origin",
&[],
None,
r##"{"x":px,"y":px} (the rulers' zero point in document pixels; the rulers and Info panel measure from it)"##,
has_doc,
|s, p| ruler_origin_cmd(s, Some(p))
),
spec!("view.resetRulerOrigin", "Reset Ruler Origin", &[], None, "{} (zero point back at the image's top-left)", has_doc, |s, _| ruler_origin_cmd(
s, None
)),
]
}
#[cfg(test)]
mod tests {
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("paint", |doc, a| {
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 50, 30), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s
}
#[test]
fn new_clipboard_document_rejects_an_unrepresentable_origin() {
let mut s = Session::new();
let mut surface = Surface::new(PixelFormat::RGBA8);
surface.write_pixel(i32::MIN, 0, &[1.0, 0.0, 0.0, 1.0]);
s.clipboard = Some(Clip { surface, bounds: Rect::from_xywh(i32::MIN, 0, 1, 1), layers: None, resolution: None });
let result = new_from_clipboard(&mut s);
assert!(result.unwrap_err().to_string().contains("clipboard origin"));
assert!(s.execute("file.newFromClipboard", json!({})).is_err());
assert!(s.documents().is_empty());
assert_eq!(s.clipboard.as_ref().unwrap().surface.rgba(i32::MIN, 0), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn paste_centers_clipboard_bounds_near_coordinate_limits() {
for origin in [i32::MAX - 512, i32::MIN + 512] {
let clip = Clip { surface: Surface::new(PixelFormat::RGBA8), bounds: Rect::from_xywh(origin, origin, 2, 2), layers: None, resolution: None };
let (dx, dy) = paste_offset(&clip, Rect::new(0, 0, 20, 20), &json!({}), false);
assert_eq!(i64::from(origin) + i64::from(dx), 9);
assert_eq!(i64::from(origin) + i64::from(dy), 9);
let clip = Clip { bounds: Rect::new(0, 0, 2, 2), ..clip };
let (dx, dy) = paste_offset(&clip, Rect::from_xywh(origin, origin, 20, 20), &json!({}), false);
assert_eq!(dx, origin + 9);
assert_eq!(dy, origin + 9);
}
}
#[test]
fn injected_clear_buffer_failure_leaves_the_document_unchanged() {
let mut s = session();
let st = s.active().unwrap();
let id = st.active_layer.unwrap();
let before = st.doc.layer(id).unwrap().surface().unwrap().clone();
let revision = st.revision;
crate::allocation::fail_next_for_test();
let result = s.execute("edit.clear", json!({}));
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for erasing pixels")));
let st = s.active().unwrap();
assert_eq!(st.doc.layer(id).unwrap().surface(), Some(&before));
assert_eq!(st.revision, revision);
}
#[test]
fn injected_copy_buffer_failure_does_not_change_document_or_clipboard() {
let mut s = session();
let st = s.active().unwrap();
let id = st.active_layer.unwrap();
let before = st.doc.layer(id).unwrap().surface().unwrap().clone();
let revision = st.revision;
crate::allocation::fail_next_for_test();
let result = s.execute("edit.copy", json!({}));
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for copying pixels")));
let st = s.active().unwrap();
assert_eq!(st.doc.layer(id).unwrap().surface(), Some(&before));
assert_eq!(st.revision, revision);
assert!(s.clipboard.is_none());
}
#[test]
fn injected_fill_result_reservation_failure_keeps_pixels_and_history() {
let mut s = session();
let st = s.active().unwrap();
let id = st.active_layer.unwrap();
let before = st.doc.layer(id).unwrap().surface().unwrap().clone();
let revision = st.revision;
let history_len = st.history.past_len();
crate::allocation::fail_next_for_test();
let result = s.execute("edit.fill", json!({"color": "#00ff00"}));
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for blending pixels")));
let st = s.active().unwrap();
assert_eq!(st.doc.layer(id).unwrap().surface(), Some(&before));
assert_eq!(st.revision, revision);
assert_eq!(st.history.past_len(), history_len);
}
#[test]
fn injected_paste_failure_does_not_change_document_or_clipboard() {
let mut s = session();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 8, "height": 8})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let before_clip = s.clipboard.clone();
let st = s.active().unwrap();
let before_doc = (*st.doc).clone();
let revision = st.revision;
let history_len = st.history.past_len();
crate::allocation::fail_next_for_test();
let result = s.execute("edit.paste", json!({}));
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for pasting pixels")));
let st = s.active().unwrap();
assert_eq!(*st.doc, before_doc);
assert_eq!(st.revision, revision);
assert_eq!(st.history.past_len(), history_len);
let after_clip = s.clipboard.as_ref().expect("clipboard remains available");
let before_clip = before_clip.as_ref().expect("clipboard was populated");
assert_eq!(after_clip.bounds, before_clip.bounds);
assert_eq!(after_clip.surface, before_clip.surface);
}
#[test]
fn injected_transform_failure_keeps_pixels_and_history() {
let mut s = session();
let st = s.active().unwrap();
let id = st.active_layer.unwrap();
let before = st.doc.layer(id).unwrap().surface().unwrap().clone();
let revision = st.revision;
let history_len = st.history.past_len();
crate::allocation::fail_next_for_test();
let result = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 20, 30]}));
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for transforming pixels")));
let st = s.active().unwrap();
assert_eq!(st.doc.layer(id).unwrap().surface(), Some(&before));
assert_eq!(st.revision, revision);
assert_eq!(st.history.past_len(), history_len);
}
fn active_bounds(s: &Session) -> Rect {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds()
}
/// New from Clipboard takes the copied document's resolution (Photoshop's Clipboard preset),
/// so a later Image Size by resolution scales from the right size.
#[test]
fn new_from_clipboard_keeps_the_source_resolution() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 60, "height": 40, "resolution": 300})).unwrap();
s.execute("select.all", json!({})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("file.newFromClipboard", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.resolution_dpi, 300.0);
}
/// #368: the clipboard as a document of its own, from New from Clipboard or a Paste with
/// nothing open, at every depth.
#[test]
fn new_from_clipboard_and_paste_with_no_document() {
for depth in [8, 16, 32] {
let mut s = Session::new();
assert!(!s.is_enabled("file.newFromClipboard") && !s.is_enabled("edit.paste"), "nothing copied yet");
assert!(s.execute("file.newFromClipboard", json!({})).is_err());
s.execute("file.new", json!({"width": 100, "height": 100, "depth": depth})).unwrap();
assert!(!s.active().unwrap().is_dirty(), "an untouched blank document is clean");
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 15})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
// With a document open: a second document, sized to the copy, its pixels at the origin.
let r = s.execute("file.newFromClipboard", json!({})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(30), Some(15)), "{r}");
assert_eq!(s.documents().len(), 2);
assert!(s.active().unwrap().is_dirty(), "a document made from clipboard pixels needs saving (#4665)");
let d = &s.active().unwrap().doc;
assert_eq!((d.size.width, d.size.height, d.layers.len()), (30, 15, 1));
assert_eq!(d.pixel_format().sample, s.documents()[0].doc.pixel_format().sample, "{depth}-bit kept");
assert_eq!(active_bounds(&s), Rect::new(0, 0, 30, 15));
let px = d.layers[0].surface().unwrap().rgba(0, 0);
assert!(px[0] > 0.99 && px[1] < 0.01 && px[3] > 0.99, "{px:?}");
// With nothing open, Paste makes the document too.
while s.active().is_some() {
s.execute("file.close", json!({})).unwrap();
}
assert!(s.is_enabled("edit.paste") && !s.is_enabled("edit.pasteSpecial.pasteInPlace"));
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(s.documents().len(), 1);
assert!(s.active().unwrap().is_dirty(), "Paste with no tab also needs a save prompt");
assert_eq!(active_bounds(&s), Rect::new(0, 0, 30, 15));
assert!(s.execute("edit.pasteSpecial.pasteInPlace", json!({})).is_ok(), "into the new document");
}
}
#[test]
fn copy_paste_in_place_and_centred() {
let mut s = session();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 30));
assert!(s.active().unwrap().doc.selection.is_none());
s.execute("edit.paste", json!({"center": [70, 70]})).unwrap();
assert_eq!(active_bounds(&s), Rect::new(60, 60, 80, 80));
assert_eq!(s.active().unwrap().doc.layers.len(), 4);
}
#[test]
fn paste_with_malformed_center_does_not_panic() {
// A short/empty `center` array must not panic (Rule 9) — it falls back to the canvas centre.
let mut s = session();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
for center in [json!([5]), json!([]), json!("nope"), json!([1, 2, 3])] {
// Fresh clipboard each time is unnecessary; copy persists. Must return Ok, never panic.
assert!(s.execute("edit.paste", json!({ "center": center })).is_ok(), "center={center}");
}
}
#[test]
fn copy_paste_between_documents() {
// Copy in one tab, paste into another: the clipboard is shared across documents.
let mut s = session(); // doc A (100x100): red rect at (10,10)-(50,30)
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
// Open a second document (a new tab), which becomes active.
s.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
assert_eq!(s.active_index().unwrap(), 1, "the new document is the active tab");
let before = s.active().unwrap().doc.layers.len();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(s.active_index().unwrap(), 1, "paste stays in the second tab");
let d = &s.active().unwrap().doc;
assert_eq!(d.layers.len(), before + 1, "pasted layer added to the second document");
let pasted = d.layers.last().unwrap().surface().unwrap();
let cb = pasted.content_bounds();
let (cx, cy) = (cb.x0 + cb.width() as i32 / 2, cb.y0 + cb.height() as i32 / 2);
let mut px = [[0.0f32; 4]; 1];
pasted.read_rgba_into(Rect::new(cx, cy, cx + 1, cy + 1), &mut px);
assert!(px[0][0] > 0.9 && px[0][1] < 0.1 && px[0][2] < 0.1, "pasted content is red: {:?}", px[0]);
}
/// #2901 (P10/P11): Paste, Paste in Place and Paste Into recomposite only where the pasted
/// layer draws, not the whole document; nothing outside that changes, and undo brings back
/// exactly the old pixels.
#[test]
fn paste_damages_only_the_pasted_layer() {
let render = |s: &Session| photocraft_compose::render(&s.active().unwrap().doc, s.active().unwrap().doc.bounds());
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 60, "height": 50, "depth": depth, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 10, "height": 8})).unwrap();
s.execute("edit.fill", json!({"color": "#3366cc"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
// Half transparent, so an opaque copy pasted in place over it shows.
s.execute("layer.setProps", json!({"opacity": 0.5})).unwrap();
for (id, p) in [
("edit.paste", json!({"center": [40, 30]})),
("edit.pasteSpecial.pasteInPlace", json!({})),
("edit.pasteSpecial.pasteInto", json!({"center": [40, 30]})),
] {
s.execute("select.rect", json!({"x": 36, "y": 27, "width": 6, "height": 4})).unwrap();
let shown = render(&s);
s.execute(id, p).unwrap();
let st = s.active().unwrap();
let area = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds();
let damage = st.last_damage.unwrap_or_else(|| panic!("{depth}-bit {id}: bounded damage"));
assert!(!damage.is_empty() && damage.intersect(&area) == damage, "{depth}-bit {id}: {damage:?} within {area:?}");
let now = render(&s);
assert_ne!(now, shown, "{depth}-bit {id}: the paste shows");
let w = shown.rect.width() as usize;
for (i, (a, b)) in shown.px.iter().zip(&now.px).enumerate() {
if !damage.contains((i % w) as i32, (i / w) as i32) {
assert_eq!(a, b, "{depth}-bit {id}: pixel {i} outside the damage changed");
}
}
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(render(&s), shown, "{depth}-bit {id}: undo");
}
}
}
/// #1035: a `depth`-bit document with a blue-grey square copied from layer `src` (at
/// (5,5)-(15,15)) and an active layer `m` above it with a hide-all mask.
fn mask_target_session(depth: u32) -> (Session, LayerId, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 40, "depth": depth})).unwrap();
let src = LayerId(s.execute("layer.new.layer", json!({"name": "src"})).unwrap()["layer"].as_u64().unwrap());
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap();
s.execute("edit.fill", json!({"color": "#3366cc"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
let m = LayerId(s.execute("layer.new.layer", json!({"name": "m"})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.layerMask.hideAll", json!({})).unwrap();
(s, src, m)
}
/// The luminosity of `src`'s pixel at (`x`, `y`): what a paste into a mask writes there.
fn luminosity(s: &Session, src: LayerId, x: i32, y: i32) -> f32 {
let p = s.active().unwrap().doc.layer(src).unwrap().surface().unwrap().rgba(x, y);
photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])
}
fn mask_at(s: &Session, id: LayerId, x: i32, y: i32) -> f32 {
s.active().unwrap().doc.layer(id).unwrap().mask.as_ref().unwrap().value(x, y)
}
#[test]
fn paste_into_a_targeted_mask_writes_the_luminosity_in_one_step() {
for depth in [8, 16, 32] {
let (mut s, src, m) = mask_target_session(depth);
let lum = luminosity(&s, src, 10, 10);
assert!(lum > 0.05 && lum < 0.95, "{depth}-bit: a mid grey ({lum})");
let (layers, steps) = (s.active().unwrap().doc.layer_count(), s.active().unwrap().history.past_len());
s.execute("edit.pasteSpecial.pasteInPlace", json!({"target": "mask"})).unwrap();
let st = s.active().unwrap();
assert_eq!(st.doc.layer_count(), layers, "{depth}-bit: no new layer");
assert_eq!(st.history.past_len(), steps + 1, "{depth}-bit: one history step");
assert_eq!(st.active_layer, Some(m));
assert!((mask_at(&s, m, 10, 10) - lum).abs() < 2.0 / 255.0, "{depth}-bit: {} vs {lum}", mask_at(&s, m, 10, 10));
assert_eq!(mask_at(&s, m, 20, 20), 0.0, "{depth}-bit: the mask outside the paste is unchanged");
assert!(st.doc.layer(m).unwrap().surface().unwrap().content_bounds().is_empty(), "{depth}-bit: the pixels are untouched");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(mask_at(&s, m, 10, 10), 0.0, "{depth}-bit: undo restores the mask");
}
}
#[test]
fn copy_with_the_mask_targeted_takes_the_mask_as_grayscale() {
for depth in [8, 16, 32] {
let (mut s, _, m) = mask_target_session(depth);
s.edit("paint mask", |doc, _| {
doc.layer_mut(m).unwrap().mask.as_mut().unwrap().surface.fill_rect(Rect::new(6, 6, 16, 12), &[0.75]);
Ok(())
})
.unwrap();
let steps = s.active().unwrap().history.past_len();
let r = s.execute("edit.copy", json!({"target": "mask"})).unwrap();
assert_eq!(r["bounds"], json!([0, 0, 40, 40]), "{depth}-bit: a mask copies whole, as in Photoshop");
assert_eq!(s.active().unwrap().history.past_len(), steps, "{depth}-bit: copy adds no history step");
let clip = s.clipboard.clone().unwrap();
let px = clip.surface.rgba(10, 10);
assert!((px[0] - 0.75).abs() < 2.0 / 255.0, "{depth}-bit: {} vs 0.75", px[0]);
assert!(clip.surface.rgba(30, 30)[0] < 0.01, "{depth}-bit: the hidden area copies as black");
assert!(px[3] > 0.99, "{depth}-bit: the copy is opaque");
// Paste it into another layer's reveal-all mask: it replaces that mask (the request
// in #2928): the square, and the black around it, land.
let m2 = LayerId(s.execute("layer.new.layer", json!({"name": "m2"})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("edit.pasteSpecial.pasteInPlace", json!({"target": "mask"})).unwrap();
assert!((mask_at(&s, m2, 10, 10) - 0.75).abs() < 2.0 / 255.0, "{depth}-bit: the copied mask lands");
assert!(mask_at(&s, m2, 30, 30) < 0.01, "{depth}-bit: the copied black replaces the reveal-all");
}
}
#[test]
fn an_untouched_mask_copies_the_whole_canvas_at_its_default() {
let (mut s, _, _) = mask_target_session(8);
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
let r = s.execute("edit.copy", json!({"target": "mask"})).unwrap();
assert_eq!(r["bounds"], json!([0, 0, 40, 40]), "a plain Reveal All mask still transfers");
assert!(s.clipboard.clone().unwrap().surface.rgba(20, 20)[0] > 0.99);
}
#[test]
fn mask_copy_takes_only_the_selection() {
let (mut s, _, m) = mask_target_session(8);
s.edit("paint mask", |doc, _| {
doc.layer_mut(m).unwrap().mask.as_mut().unwrap().surface.fill_rect(Rect::new(6, 6, 16, 12), &[0.75]);
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 8, "y": 8, "width": 4, "height": 4})).unwrap();
let r = s.execute("edit.copy", json!({"target": "mask"})).unwrap();
assert_eq!(r["bounds"], json!([8, 8, 4, 4]));
let clip = s.clipboard.clone().unwrap();
assert!((clip.surface.rgba(9, 9)[0] - 0.75).abs() < 2.0 / 255.0);
assert!(clip.surface.rgba(5, 5)[3] < 0.01, "nothing outside the selection");
}
#[test]
fn mask_copy_needs_a_mask_and_fails_gracefully() {
let mut s = Session::new();
assert!(s.execute("edit.copy", json!({"target": "mask"})).is_err(), "no document");
s.execute("file.new", json!({"width": 20, "height": 20})).unwrap();
assert!(s.execute("edit.copy", json!({"target": "mask"})).is_err(), "the layer has no mask");
assert!(s.clipboard.is_none(), "a refused copy leaves the clipboard alone");
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
assert!(s.execute("edit.copy", json!({"target": "mask"})).is_ok());
// An adjustment layer has no pixels of its own: Copy takes the layer whole (#2344), and
// its mask while that is targeted (#2928).
s.execute("file.new", json!({"width": 20, "height": 20})).unwrap();
let a = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layer": a})).unwrap();
assert!(s.is_enabled("edit.copy"), "the layer itself can be copied");
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
assert!(s.is_enabled_with("edit.copy", &json!({"target": "mask"})), "the mask can be copied");
}
#[test]
fn a_copied_mask_travels_to_another_document() {
let (mut s, _, m) = mask_target_session(8);
s.edit("paint mask", |doc, _| {
doc.layer_mut(m).unwrap().mask.as_mut().unwrap().surface.fill_rect(Rect::new(6, 6, 16, 12), &[0.75]);
Ok(())
})
.unwrap();
s.execute("edit.copy", json!({"target": "mask"})).unwrap();
s.execute("file.new", json!({"width": 40, "height": 40})).unwrap();
let n = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("edit.pasteSpecial.pasteInPlace", json!({"target": "mask"})).unwrap();
assert!((mask_at(&s, n, 10, 10) - 0.75).abs() < 2.0 / 255.0, "the mask arrived in the second document");
assert!(mask_at(&s, n, 30, 30) < 0.01, "it replaced the reveal-all mask there");
}
#[test]
fn paste_follows_the_mask_view_and_is_placed_like_a_paste() {
let (mut s, src, m) = mask_target_session(8);
let lum = luminosity(&s, src, 10, 10);
// ⌥-click the mask thumbnail: the canvas shows the mask, and pasting targets it.
s.execute(crate::mask_view_cmds::ID, json!({"layer": m.0, "mode": "gray"})).unwrap();
let r = s.execute("edit.paste", json!({"center": [30, 30]})).unwrap();
assert_eq!(r["offset"], json!([20, 20]));
assert!((mask_at(&s, m, 30, 30) - lum).abs() < 2.0 / 255.0);
assert_eq!(mask_at(&s, m, 10, 10), 0.0);
assert_eq!(s.active().unwrap().doc.layers.len(), 3, "Background, src, m");
}
#[test]
fn paste_into_and_outside_a_targeted_mask_keep_to_the_selection() {
let (mut s, src, m) = mask_target_session(16);
let lum = luminosity(&s, src, 10, 10);
// The paste is centred on the selection, so lands in place: only the selection takes it.
s.execute("select.rect", json!({"x": 8, "y": 8, "width": 4, "height": 4})).unwrap();
s.execute("edit.pasteSpecial.pasteInto", json!({"target": "mask"})).unwrap();
assert!((mask_at(&s, m, 9, 9) - lum).abs() < 2.0 / 255.0);
assert_eq!(mask_at(&s, m, 7, 7), 0.0, "pasted, but outside the selection");
assert!(s.active().unwrap().doc.selection.is_none());
assert_eq!(s.active().unwrap().doc.layers.len(), 3, "no new layer");
s.execute("layer.layerMask.hideAll", json!({})).unwrap();
s.execute("select.rect", json!({"x": 8, "y": 8, "width": 4, "height": 4})).unwrap();
s.execute("edit.pasteSpecial.pasteOutside", json!({"target": "mask"})).unwrap();
assert_eq!(mask_at(&s, m, 9, 9), 0.0, "Paste Outside leaves the selection alone");
assert!((mask_at(&s, m, 7, 7) - lum).abs() < 2.0 / 255.0);
}
#[test]
fn paste_follows_a_targeted_alpha_channel() {
let (mut s, src, _) = mask_target_session(8);
let lum = luminosity(&s, src, 10, 10);
s.execute("channel.new", json!({})).unwrap();
s.execute("edit.pasteSpecial.pasteInPlace", json!({"target": {"channel": 0}})).unwrap();
let c = &s.active().unwrap().doc.channels[0].surface;
assert!((c.sample_channel(10, 10, 0) - lum).abs() < 2.0 / 255.0);
assert_eq!(s.active().unwrap().doc.layers.len(), 3, "no new layer");
}
#[test]
fn paste_into_a_missing_mask_or_channel_is_an_error() {
let (mut s, _, m) = mask_target_session(8);
s.edit("drop mask", |doc, _| {
doc.layer_mut(m).unwrap().mask = None;
Ok(())
})
.unwrap();
let layers = s.active().unwrap().doc.layer_count();
for target in [json!("mask"), json!({"channel": 7}), json!("quickMask")] {
for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace"] {
assert!(s.execute(id, json!({ "target": target })).is_err(), "{id} {target}");
}
}
assert_eq!(s.active().unwrap().doc.layer_count(), layers, "nothing pasted as a layer");
}
#[test]
fn paste_between_different_color_modes() {
// Copy from an RGB document, paste into a Grayscale one: the clipboard converts to the
// target document's format (a realistic between-tabs case that must not panic).
let mut s = session(); // RGB, red rect
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("file.new", json!({"width": 120, "height": 120, "mode": "gray"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert_eq!(d.mode, photocraft_doc::ColorMode::Grayscale);
let pasted = d.layers.last().unwrap().surface().unwrap();
assert_eq!(pasted.format().mode, photocraft_color::ColorMode::Grayscale, "pasted layer is grayscale");
assert!(!pasted.content_bounds().is_empty(), "pasted content exists");
}
#[test]
fn transform_scale_grows_content() {
let mut s = session(); // red rect (10,10)-(50,30): 40x20
s.execute("edit.transform", json!({"matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
let d = &s.active().unwrap().doc;
let cb = d.layers.last().unwrap().surface().unwrap().content_bounds();
// Scaled ~2x about the origin (≈ 80x40 at ≈ (20,20)); allow ~1px bilinear edge bleed.
assert!((cb.x0 - 20).abs() <= 2 && (cb.y0 - 20).abs() <= 2, "origin ~ (20,20): {cb:?}");
assert!((cb.width() as i32 - 80).abs() <= 3 && (cb.height() as i32 - 40).abs() <= 3, "size ~ 80x40: {cb:?}");
}
#[test]
fn cut_and_clear_on_the_background_fill_with_the_background_colour() {
// The Background keeps its transparency lock: cutting fills with the background colour.
let fresh = || {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
s.tools.background = [1.0, 0.0, 0.0, 1.0];
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap();
s
};
let px = |s: &Session, i: usize, x: i32, y: i32| s.active().unwrap().doc.layers[i].surface().unwrap().pixel(x, y);
for cmd in ["edit.cut", "edit.clear", "layer.new.layerViaCut"] {
let mut s = fresh();
s.execute(cmd, json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert!(d.is_background(d.layers[0].id), "{cmd}: still the Background");
assert_eq!(px(&s, 0, 8, 8), vec![1.0, 0.0, 0.0, 1.0], "{cmd}: cut area is the background colour");
assert_eq!(px(&s, 0, 30, 20), vec![1.0, 1.0, 1.0, 1.0], "{cmd}: outside untouched");
}
// Layer via Cut still lifts the original pixels onto the new layer.
let mut s = fresh();
s.execute("layer.new.layerViaCut", json!({})).unwrap();
assert_eq!(px(&s, 1, 8, 8), vec![1.0, 1.0, 1.0, 1.0]);
// Once it's a normal layer, cutting makes transparency again.
let mut s = fresh();
s.execute("layer.new.layerFromBackground", json!({})).unwrap();
s.execute("edit.clear", json!({})).unwrap();
assert_eq!(px(&s, 0, 8, 8)[3], 0.0);
}
#[test]
fn layer_via_copy_of_a_background_keeps_colour_and_partial_selection() {
// #668: lift reads only the selected area now; the result must be what converting the
// whole layer gave: the colour, with the selection's coverage as alpha.
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": "#336699"})).unwrap();
s.edit("partial selection", |doc, _| {
let mut m = photocraft_raster::Surface::new(PixelFormat::GRAY8);
m.fill_rect(Rect::new(5, 5, 15, 15), &[1.0]);
m.fill_rect(Rect::new(15, 5, 20, 15), &[0.5]);
doc.selection = Some(m);
Ok(())
})
.unwrap();
let id = s.execute("layer.new.layerViaCopy", json!({})).unwrap()["layer"].as_u64().unwrap();
let d = s.active().unwrap();
let surf = d.doc.layer(LayerId(id)).unwrap().surface().unwrap();
assert!(surf.format().alpha, "@{depth}");
let full = surf.read_region(Rect::new(6, 6, 7, 7));
let half = surf.read_region(Rect::new(16, 6, 17, 7));
for (got, want) in full.iter().zip([0.2, 0.4, 0.6, 1.0]) {
assert!((got - want).abs() < 0.01, "@{depth}: {full:?}");
}
assert!((half[3] - 0.5).abs() < 0.01 && (half[0] - 0.2).abs() < 0.01, "@{depth}: {half:?}");
assert_eq!(surf.content_bounds(), Rect::new(5, 5, 20, 15), "@{depth}");
}
}
#[test]
fn cut_clears_and_layer_via_copy_cut() {
let mut s = session();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 20})).unwrap();
s.execute("edit.cut", json!({})).unwrap();
assert_eq!(active_bounds(&s), Rect::new(20, 10, 50, 30));
let mut s = session();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 20})).unwrap();
s.execute("layer.new.layerViaCut", json!({})).unwrap();
assert_eq!(active_bounds(&s), Rect::new(10, 10, 20, 30));
let d = &s.active().unwrap().doc;
assert_eq!(d.layers[1].surface().unwrap().content_bounds(), Rect::new(20, 10, 50, 30));
// Without a selection ⌘J duplicates the layer.
let mut s = session();
s.execute("layer.new.layerViaCopy", json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert_eq!(d.layers.len(), 3);
assert!(d.layers[2].name.ends_with("copy"));
}
/// #1096: Layer via Copy and Cut deselect, as Photoshop does, so a following Free Transform
/// moves the whole new layer; one undo brings back both the selection and the old layers.
#[test]
fn layer_via_copy_and_cut_deselect_in_one_step() {
for cmd in ["layer.new.layerViaCopy", "layer.new.layerViaCut"] {
let mut s = session();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 20})).unwrap();
let (layers, steps) = (s.active().unwrap().doc.layers.len(), s.active().unwrap().history.past_len());
s.execute(cmd, json!({})).unwrap();
let st = s.active().unwrap();
assert!(st.doc.selection.is_none(), "{cmd}: deselected");
assert_eq!((st.doc.layers.len(), st.history.past_len()), (layers + 1, steps + 1), "{cmd}");
assert!(s.undo());
let st = s.active().unwrap();
assert_eq!(st.doc.layers.len(), layers, "{cmd}: undo removes the layer");
assert!(st.doc.selection.is_some(), "{cmd}: and restores the selection");
}
}
/// #2777: with several layers selected and no pixel selection, Layer via Copy (Cmd+J) copies
/// every selected layer in one step, each copy above its original and the copies selected.
/// With a pixel selection it still copies the active layer's selected pixels only.
#[test]
fn layer_via_copy_duplicates_every_selected_layer() {
let mut s = session();
let a = s.active().unwrap().active_layer.unwrap();
let b = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.select", json!({"layer": b.0, "mode": "toggle"})).unwrap();
let steps = s.active().unwrap().history.past_len();
s.execute("layer.new.layerViaCopy", json!({})).unwrap();
let st = s.active().unwrap();
let ids: Vec<LayerId> = st.doc.layers.iter().map(|l| l.id).collect();
assert_eq!(ids.len(), 5, "background, a, a copy, b, b copy");
assert_eq!((ids[1], ids[3]), (a, b), "each copy sits above its original");
let mut selected = st.selected_layers();
selected.sort_by_key(|l| l.0);
assert_eq!(selected, vec![ids[2], ids[4]], "the copies become the selection");
assert_eq!(st.history.past_len(), steps + 1, "one undo step");
assert!(s.undo());
s.execute("layer.select", json!({"layer": b.0})).unwrap();
s.execute("layer.select", json!({"layer": a.0, "mode": "toggle"})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
s.execute("layer.new.layerViaCopy", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.layers.len(), 4, "a pixel selection copies one layer's pixels");
}
fn active_content(s: &Session) -> LayerContent {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().content.clone()
}
/// #541, #2428: with no selection, Copy takes a smart object whole and Paste makes a smart
/// object again, with contents of its own; with a selection, the selected pixels paste as
/// pixels. Cut edits pixels, so it's refused; Layer via Copy and Duplicate Layer copy the layer.
#[test]
fn copy_a_smart_object_layer() {
let mut s = session();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let LayerContent::Smart(original) = active_content(&s) else { panic!("smart object") };
assert!(s.is_enabled("edit.copy"));
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([10, 10, 40, 20]), "no selection: the whole layer");
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
let LayerContent::Smart(pasted) = active_content(&s) else { panic!("the paste is a smart object (#2428)") };
assert_eq!(pasted.source, original.source, "the same contents");
assert_ne!(pasted.contents_id, original.contents_id, "but not linked to the copied one");
assert_eq!(active_bounds(&s), Rect::new(10, 10, 50, 30));
s.execute("edit.paste", json!({"center": [70, 70]})).unwrap();
assert!(matches!(active_content(&s), LayerContent::Smart(_)));
assert_eq!(active_bounds(&s), Rect::new(50, 60, 90, 80), "placed as pasted pixels are");
s.undo();
s.undo();
assert!(matches!(active_content(&s), LayerContent::Smart(_)));
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([10, 10, 10, 10]), "only the selected part");
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
assert!(matches!(active_content(&s), LayerContent::Raster(_)), "a selection copies pixels");
assert_eq!(active_bounds(&s), Rect::new(10, 10, 20, 20));
s.undo();
let cut = s.execute("edit.cut", json!({})).unwrap_err().to_string();
assert!(cut.contains("not directly editable"), "{cut}");
assert!(matches!(active_content(&s), LayerContent::Smart(_)));
s.execute("select.deselect", json!({})).unwrap();
s.execute("layer.new.layerViaCopy", json!({})).unwrap();
assert!(matches!(active_content(&s), LayerContent::Smart(_)), "⌘J without a selection duplicates the smart object");
s.execute("layer.duplicate", json!({})).unwrap();
assert!(matches!(active_content(&s), LayerContent::Smart(_)));
assert_eq!(s.active().unwrap().doc.layers.len(), 4);
}
/// #2478: with several layers selected and no pixel selection, Copy takes every one of them
/// and Paste recreates them all, in order, as one undo step; with a pixel selection Copy still
/// takes the active layer's selected pixels only.
#[test]
fn copy_and_paste_several_selected_layers() {
let mut s = session();
let a = s.active().unwrap().active_layer.unwrap();
let b = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
s.edit("paint", |doc, _| {
doc.layer_mut(b).unwrap().surface_mut().unwrap().fill_rect(Rect::new(60, 60, 80, 90), &[0.0, 0.0, 1.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.select", json!({"layer": b.0, "mode": "toggle"})).unwrap();
assert!(s.is_enabled("edit.copy"));
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([10, 10, 70, 80]), "both layers' pixels");
let before = s.active().unwrap().doc.layers.len();
let steps = s.active().unwrap().history.past_len();
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
let st = s.active().unwrap();
assert_eq!(st.doc.layers.len(), before + 2, "every selected layer is pasted");
assert_eq!(st.history.past_len(), steps + 1, "one undo step");
let n = st.doc.layers.len();
let (pa, pb) = (st.doc.layers[n - 2].surface().unwrap(), st.doc.layers[n - 1].surface().unwrap());
assert_eq!(pa.content_bounds(), Rect::new(10, 10, 50, 30), "the lower layer first");
assert_eq!(pb.content_bounds(), Rect::new(60, 60, 80, 90));
assert!(s.undo());
assert_eq!(s.active().unwrap().doc.layers.len(), before);
// A pixel selection takes the active layer's selected pixels, as a single layer.
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 30, "height": 30})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.layers.len(), before + 1);
}
/// #2478, #3588: several layers copied in one document paste into another, in that document's
/// bit depth and with their own pixels, in order, as one undo step.
#[test]
fn paste_several_layers_into_another_document() {
for depth in [16, 32] {
let mut s = session();
let a = s.active().unwrap().active_layer.unwrap();
let b = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
s.edit("paint", |doc, _| {
doc.layer_mut(b).unwrap().surface_mut().unwrap().fill_rect(Rect::new(60, 60, 80, 90), &[0.0, 0.0, 1.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.select", json!({"layer": b.0, "mode": "toggle"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("file.new", json!({"width": 120, "height": 120, "depth": depth})).unwrap();
assert_eq!(s.active_index().unwrap(), 1, "the new document is the active tab");
let (before, steps) = (s.active().unwrap().doc.layers.len(), s.active().unwrap().history.past_len());
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
let st = s.active().unwrap();
assert_eq!(st.doc.layers.len(), before + 2, "{depth}-bit: both layers arrive");
assert_eq!(st.history.past_len(), steps + 1, "{depth}-bit: one undo step");
let n = st.doc.layers.len();
let (pa, pb) = (st.doc.layers[n - 2].surface().unwrap(), st.doc.layers[n - 1].surface().unwrap());
assert_eq!(pa.format().sample, st.doc.depth, "{depth}-bit: converted to the target depth");
assert_eq!(pb.format().sample, st.doc.depth, "{depth}-bit: converted to the target depth");
assert_eq!(pa.content_bounds(), Rect::new(10, 10, 50, 30), "{depth}-bit: the lower layer first");
assert_eq!(pb.content_bounds(), Rect::new(60, 60, 80, 90));
let (mut red, mut blue) = ([[0.0f32; 4]; 1], [[0.0f32; 4]; 1]);
pa.read_rgba_into(Rect::new(20, 20, 21, 21), &mut red);
pb.read_rgba_into(Rect::new(70, 70, 71, 71), &mut blue);
assert!(red[0][0] > 0.9 && red[0][1] < 0.1 && red[0][2] < 0.1, "{depth}-bit: red kept: {:?}", red[0]);
assert!(blue[0][2] > 0.9 && blue[0][0] < 0.1 && blue[0][1] < 0.1, "{depth}-bit: blue kept: {:?}", blue[0]);
assert!(s.undo());
assert_eq!(s.active().unwrap().doc.layers.len(), before, "{depth}-bit: undo removes both");
assert_eq!(s.documents().len(), 2, "the source document is untouched");
}
}
/// #2478: the Background in a multi-layer copy pastes as an ordinary, unlocked layer.
#[test]
fn copying_the_background_with_another_layer_pastes_an_ordinary_layer() {
let mut s = session();
let a = s.active().unwrap().active_layer.unwrap();
let bg = s.active().unwrap().doc.layers[0].id;
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.select", json!({"layer": bg.0, "mode": "toggle"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
let st = s.active().unwrap();
assert_eq!(st.doc.layers.len(), 4);
let pasted = &st.doc.layers[st.doc.layers.len() - 2];
assert!(!st.doc.is_background(pasted.id));
assert_eq!(pasted.locks, Default::default());
assert!(st.doc.is_background(st.doc.layers[0].id), "the real Background is untouched");
}
/// #2428: a smart object copied in one document pastes into another as a smart object, in
/// that document's bit depth.
#[test]
fn paste_a_smart_object_into_another_document() {
let mut s = session();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("file.new", json!({"width": 60, "height": 40, "depth": 16})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
let LayerContent::Smart(sm) = active_content(&s) else { panic!("the paste is a smart object") };
assert_eq!(sm.cache.map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits");
assert_eq!(active_bounds(&s), Rect::new(10, 10, 50, 30), "on the canvas: in place");
}
/// #3584: a PSD-embedded linked smart object (its bytes in the document's `lnk2` block, its
/// source a uuid) can still be opened after Copy + Paste into another document.
#[test]
fn paste_keeps_a_psd_embedded_smart_object_openable() {
use std::sync::Arc;
let mut s = session();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let outer = s.active().unwrap().active_layer.unwrap();
s.edit("embedded linked source", |doc, _| {
let LayerContent::Smart(sm) = &mut doc.layer_mut(outer).ok_or(EngineError::NoDocument)?.content else {
return Err(EngineError::Other("not a smart object".into()));
};
sm.source = photocraft_doc::SmartSource::Linked { path: "uuid-1".into() };
let linked = photocraft_io::linked::LinkedFile { uuid: "uuid-1".into(), file_name: "in.png".into(), bytes: vec![1, 2, 3, 4] };
doc.metadata.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(photocraft_io::linked::encode_linked_file(&linked))));
Ok(())
})
.unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
let LayerContent::Smart(sm) = active_content(&s) else { panic!("the paste is a smart object") };
assert!(
crate::smart_cmds::source_bytes(&s.active().unwrap().doc.metadata, &sm.source).is_some(),
"the pasted PSD-embedded linked source must resolve (#3584)"
);
}
/// Type and shape layers copy their rendered pixels too; groups and adjustments have none.
#[test]
fn copy_reads_any_layer_that_shows_pixels() {
let mut s = session();
s.execute("type.create", json!({"text": "Hi", "size": 30, "x": 20, "y": 60})).unwrap();
assert!(!s.is_enabled("edit.cut"));
assert!(s.execute("edit.copy", json!({})).is_ok());
s.execute("layer.groupLayers", json!({})).unwrap();
assert!(s.is_enabled("edit.copy"), "no selection: the group is copied whole (#2670)");
s.execute("select.all", json!({})).unwrap();
assert!(!s.is_enabled("edit.copy"));
}
/// #2670: with no selection, Copy takes a type layer, a shape layer or a group whole and
/// Paste recreates it, into another document too (with fresh ids, in its bit depth); with a
/// selection, Copy takes pixels.
#[test]
fn copy_paste_keeps_type_shape_and_group_layers() {
let mut s = session();
s.execute("type.create", json!({"text": "Hi", "size": 30, "x": 20, "y": 60})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
let LayerContent::Text(t) = active_content(&s) else { panic!("the paste is a type layer") };
assert_eq!(t.text, "Hi");
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert!(matches!(active_content(&s), LayerContent::Raster(_)), "a selection copies pixels");
s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
let LayerContent::Group(src) = active_content(&s) else { panic!("a group") };
s.execute("edit.copy", json!({})).unwrap();
s.execute("file.new", json!({"width": 60, "height": 40, "depth": 16})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
let LayerContent::Group(g) = active_content(&s) else { panic!("the paste is a group") };
let [shape] = g.children.as_slice() else { panic!("one child") };
assert!(matches!(shape.content, LayerContent::Shape(_)), "the shape stays a shape");
assert_ne!(shape.id, src.children[0].id, "fresh ids");
assert_eq!(shape.surface().map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits");
}
/// #2344: fill and adjustment layers have no pixels of their own, so with no selection Copy
/// takes them whole and Paste recreates them, in the same or another document, in one step.
#[test]
fn copy_paste_keeps_fill_and_adjustment_layers() {
for (depth, other) in [(8, 16), (16, 32), (32, 8)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 60, "height": 40, "depth": depth})).unwrap();
s.execute("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff", "angle": 0})).unwrap();
let LayerContent::Fill(fill) = active_content(&s) else { panic!("a fill layer") };
let src = s.active().unwrap().active_layer.unwrap();
assert!(s.is_enabled("edit.copy"), "{depth}-bit: a fill layer can be copied");
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 60, 40]), "{depth}-bit: what it shows");
let clip = s.clipboard.clone().unwrap();
assert!(clip.surface.rgba(59, 20)[0] > clip.surface.rgba(0, 20)[0], "{depth}-bit: the clip holds the gradient's pixels");
let steps = s.active().unwrap().history.past_len();
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{depth}-bit: one history step");
assert_eq!(active_content(&s), LayerContent::Fill(fill.clone()), "{depth}-bit: the paste is the same fill layer");
assert_ne!(s.active().unwrap().active_layer, Some(src), "{depth}-bit: a new layer");
s.undo();
assert_eq!(s.active().unwrap().active_layer, Some(src), "{depth}-bit: undo removes the paste");
s.redo();
assert!(matches!(active_content(&s), LayerContent::Fill(_)), "{depth}-bit: redo brings it back");
s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap();
let LayerContent::Adjustment(levels) = active_content(&s) else { panic!("an adjustment layer") };
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 60, 40]), "{depth}-bit: it applies to the canvas");
s.execute("file.new", json!({"width": 30, "height": 30, "depth": other})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(active_content(&s), LayerContent::Adjustment(levels), "{depth}-bit: the same Levels layer in the {other}-bit document");
assert_eq!(s.active().unwrap().doc.layers.len(), 2, "{depth}-bit: pasted above the Background");
}
}
/// #2344: several adjustment layers, which show nothing alone, still copy and paste together.
#[test]
fn several_adjustment_layers_copy_together() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 40})).unwrap();
let a = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layer": a})).unwrap();
s.execute("layer.select", json!({"layer": b, "mode": "toggle"})).unwrap();
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([0, 0, 40, 40]));
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert_eq!(d.layers.len(), 5, "both are pasted");
assert!(d.layers[3..].iter().all(|l| matches!(l.content, LayerContent::Adjustment(_))));
}
#[test]
fn merge_visible_keeps_hidden_layers() {
let mut s = session();
s.execute("layer.new.layer", json!({})).unwrap();
let hidden = s.active().unwrap().active_layer.unwrap();
s.execute("layer.setProps", json!({"layer": hidden.0, "visible": false})).unwrap();
s.execute("layer.mergeVisible", json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert_eq!(d.layers.len(), 2);
assert_eq!(d.layers[0].name, "Background");
assert_eq!(d.layers[1].id, hidden);
assert_eq!(d.layers[0].surface().unwrap().pixel(20, 20)[..3], [1.0, 0.0, 0.0]);
}
#[test]
fn auto_tone_stretches_a_low_contrast_layer() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
s.edit("grey ramp", |doc, _| {
let surf = doc.layers[0].surface_mut().unwrap();
for x in 0..64 {
let v = 0.3 + 0.4 * x as f32 / 63.0;
surf.fill_rect(Rect::new(x, 0, x + 1, 64), &[v, v * 0.9, v * 0.8, 1.0]);
}
Ok(())
})
.unwrap();
for cmd in ["image.autoTone", "image.autoContrast", "image.autoColor"] {
s.execute(cmd, json!({})).unwrap();
let d = &s.active().unwrap().doc;
let (a, b) = (d.layers[0].surface().unwrap().pixel(0, 0), d.layers[0].surface().unwrap().pixel(63, 0));
// Auto Contrast keeps colour (shared points): only the extreme channels reach 0 and 1.
let (lo, hi) = if cmd == "image.autoContrast" { (a[2], b[0]) } else { (a[0], b[0]) };
assert!(lo < 0.05 && hi > 0.95, "{cmd}: {a:?} {b:?}");
s.undo();
}
}
/// #352: ⌥⇧⌘T steps and repeats: each press adds a copy one transform further on, and each
/// is one undo step.
#[test]
fn transform_again_on_a_copy_steps_and_repeats() {
let mut s = session();
assert!(s.execute("edit.transform.againCopy", json!({})).is_err(), "nothing to repeat yet");
assert_eq!(s.active().unwrap().doc.layers.len(), 2, "a refusal leaves no copy");
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 10, 0]})).unwrap();
let steps = s.active().unwrap().history.past_len();
for (i, x) in [30, 40].into_iter().enumerate() {
s.execute("edit.transform.againCopy", json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert_eq!(d.layers.len(), 3 + i);
assert_eq!(active_bounds(&s), Rect::new(x, 10, x + 40, 30), "the copy moved one step further");
assert_eq!(s.active().unwrap().history.past_len(), steps + 1 + i, "one history step per press");
}
assert_eq!(s.active().unwrap().history.undo_label(), Some("Transform Again"));
// The original and the first copy stay where they were.
let d = &s.active().unwrap().doc;
let b = |i: usize| d.layers[i].surface().unwrap().content_bounds();
assert_eq!((b(1), b(2)), (Rect::new(20, 10, 60, 30), Rect::new(30, 10, 70, 30)));
// Undo removes the copy and its move together.
s.undo();
assert_eq!(s.active().unwrap().doc.layers.len(), 3);
assert_eq!(active_bounds(&s), Rect::new(30, 10, 70, 30));
// A copy that can't move (position locked) is taken back.
let id = s.active().unwrap().active_layer.unwrap();
s.execute("layer.setProps", json!({"layer": id.0, "locks": {"position": true}})).unwrap();
let layers = s.active().unwrap().doc.layers.len();
assert!(s.execute("edit.transform.againCopy", json!({})).unwrap_err().to_string().contains("locked"));
assert_eq!(s.active().unwrap().doc.layers.len(), layers);
assert!(!s.active().unwrap().history.can_redo(), "nothing to redo");
}
#[test]
fn toggle_last_state_and_transform_again() {
let mut s = session();
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 10, 0]})).unwrap();
s.execute("edit.transform.again", json!({})).unwrap();
assert_eq!(active_bounds(&s), Rect::new(30, 10, 70, 30));
s.execute("edit.toggleLastState", json!({})).unwrap();
assert_eq!(active_bounds(&s), Rect::new(20, 10, 60, 30));
s.execute("edit.toggleLastState", json!({})).unwrap();
assert_eq!(active_bounds(&s), Rect::new(30, 10, 70, 30));
}
#[test]
fn guides_are_undoable() {
let mut s = session();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 40})).unwrap();
s.execute("view.newGuide", json!({"orientation": "horizontal", "position": 12.5})).unwrap();
s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": 60})).unwrap();
let g = &s.active().unwrap().doc.guides;
assert_eq!((g.vertical.clone(), g.horizontal.clone()), (vec![60.0], vec![12.5]));
s.execute("view.deleteGuide", json!({"orientation": "horizontal", "index": 0})).unwrap();
s.undo();
assert_eq!(s.active().unwrap().doc.guides.horizontal, vec![12.5]);
s.execute("view.clearGuides", json!({})).unwrap();
assert!(s.active().unwrap().doc.guides.vertical.is_empty());
assert!(s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 3, "position": 1})).is_err());
}
/// #2955: the rulers' zero point is set and reset by command, undoable, and bad params are
/// refused without touching the document.
#[test]
fn ruler_origin_is_set_reset_and_undoable() {
let mut s = session();
assert_eq!(s.active().unwrap().doc.ruler_origin, [0.0, 0.0]);
assert_eq!(s.execute("view.setRulerOrigin", json!({"x": 40, "y": 12.5})).unwrap(), json!({"x": 40.0, "y": 12.5}));
assert_eq!(s.active().unwrap().doc.ruler_origin, [40.0, 12.5]);
s.execute("view.resetRulerOrigin", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.ruler_origin, [0.0, 0.0]);
s.undo();
assert_eq!(s.active().unwrap().doc.ruler_origin, [40.0, 12.5]);
for bad in [json!({}), json!({"x": 1}), json!({"x": "a", "y": 2}), json!({"x": 1e300, "y": 0}), json!({"x": -2e7, "y": 0}), json!(null), json!([1, 2])]
{
assert!(s.execute("view.setRulerOrigin", bad.clone()).is_err(), "{bad} is refused");
}
assert_eq!(s.active().unwrap().doc.ruler_origin, [40.0, 12.5]);
let mut empty = Session::new();
assert!(empty.execute("view.setRulerOrigin", json!({"x": 1, "y": 2})).is_err());
assert!(empty.execute("view.resetRulerOrigin", json!({})).is_err());
}
/// The last guide added in `orientation` for `position`.
fn new_guide(s: &mut Session, orientation: &str, position: Value) -> f64 {
s.execute("view.newGuide", json!({"orientation": orientation, "position": position})).unwrap();
let g = &s.active().unwrap().doc.guides;
*if orientation == "vertical" { &g.vertical } else { &g.horizontal }.last().unwrap()
}
#[test]
fn guide_positions_take_photoshop_units() {
let mut s = Session::new();
// 4 × 2 inches at 100 ppi.
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap();
// Percent follows the guide's own axis: the width for a vertical guide, the height for a horizontal one.
assert_eq!(new_guide(&mut s, "vertical", json!("50%")), 200.0);
assert_eq!(new_guide(&mut s, "horizontal", json!("50%")), 100.0);
for one_inch in ["1in", "1 in", "2.54cm", "25.4 mm", "72pt", "6pc", "100px", "100", "1,0 inch"] {
assert!((new_guide(&mut s, "vertical", json!(one_inch)) - 100.0).abs() < 1e-3, "{one_inch}");
}
// Fractional and negative positions are kept; a plain number is pixels, as before.
assert_eq!(new_guide(&mut s, "horizontal", json!("12.5px")), 12.5);
assert_eq!(new_guide(&mut s, "vertical", json!("-10px")), -10.0);
assert_eq!(new_guide(&mut s, "vertical", json!(40)), 40.0);
// Points and picas follow Units & Rulers › Point Size.
s.execute("prefs.set", json!({"path": "unitsAndRulers.pointSize", "value": "traditional"})).unwrap();
assert!((new_guide(&mut s, "vertical", json!("72.27pt")) - 100.0).abs() < 1e-3);
// A guide moved with a unit lands where a new one would.
s.execute("view.moveGuide", json!({"orientation": "horizontal", "index": 0, "position": "25%"})).unwrap();
assert_eq!(s.active().unwrap().doc.guides.horizontal[0], 50.0);
}
#[test]
fn new_guides_keep_their_own_colour_through_moves_deletes_and_undo() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200})).unwrap();
let color = |s: &Session, vertical: bool, p: f64| s.active().unwrap().doc.guides.color(vertical, p);
// Without a colour a guide uses the preference colour, as ruler-drag guides always did.
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap();
assert_eq!(color(&s, true, 10.0), None);
assert!(s.active().unwrap().doc.guides.colors.is_empty());
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20, "color": "magenta"})).unwrap();
s.execute("view.newGuide", json!({"orientation": "horizontal", "position": 20, "color": "#123456"})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 30, "color": "LightBlue"})).unwrap();
assert_eq!(color(&s, true, 20.0), Some([0xff, 0x00, 0xff]));
assert_eq!(color(&s, false, 20.0), Some([0x12, 0x34, 0x56]), "the same position across is a different guide");
assert_eq!(color(&s, true, 30.0), Some([0x33, 0x66, 0xff]), "names are case-insensitive");
// Moving takes the colour along.
s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 1, "position": 25})).unwrap();
assert_eq!((color(&s, true, 20.0), color(&s, true, 25.0)), (None, Some([0xff, 0x00, 0xff])));
// Undo restores it where it was.
s.undo();
assert_eq!((color(&s, true, 20.0), color(&s, true, 25.0)), (Some([0xff, 0x00, 0xff]), None));
// Deleting drops it; clearing drops them all.
s.execute("view.deleteGuide", json!({"orientation": "vertical", "index": 2})).unwrap();
assert_eq!(color(&s, true, 30.0), None);
assert_eq!(s.active().unwrap().doc.guides.colors.len(), 2);
s.execute("view.clearGuides", json!({})).unwrap();
assert!(s.active().unwrap().doc.guides.colors.is_empty());
}
#[test]
fn moving_one_of_two_coloured_guides_at_one_position_leaves_the_other_its_colour() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200})).unwrap();
for _ in 0..2 {
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 50, "color": "green"})).unwrap();
}
s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": 60})).unwrap();
let g = &s.active().unwrap().doc.guides;
assert_eq!((g.color(true, 50.0), g.color(true, 60.0)), (Some([0x33, 0xff, 0x33]), Some([0x33, 0xff, 0x33])));
}
#[test]
fn cyan_is_the_default_guide_colour_so_ruler_guides_read_as_cyan() {
let default = crate::prefs::Preferences::default().guides_grid_and_slices.guide_color;
let cyan = GUIDE_COLORS.iter().find(|(name, ..)| *name == "cyan").map(|(.., rgb)| *rgb);
assert_eq!(cyan, crate::prefs::parse_hex(&default));
}
#[test]
fn bad_guide_colours_are_refused_without_a_guide() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200})).unwrap();
let undo_before = s.active().unwrap().history.can_undo();
for bad in [json!("puce"), json!("#12345"), json!("#gggggg"), json!(""), json!(7), json!(null), json!(["cyan"])] {
assert!(s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10, "color": bad.clone()})).is_err(), "{bad}");
}
let g = &s.active().unwrap().doc.guides;
assert!(g.vertical.is_empty() && g.colors.is_empty());
assert_eq!(s.active().unwrap().history.can_undo(), undo_before);
}
#[test]
fn editing_a_guide_changes_what_is_given_in_one_undoable_step() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20, "color": "magenta"})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 30})).unwrap();
let guides = |s: &Session| s.active().unwrap().doc.guides.clone();
// Position only: the guide keeps its index and its own colour.
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "position": "50%"})).unwrap();
let g = guides(&s);
assert_eq!(g.vertical, vec![10.0, 200.0, 30.0]);
assert_eq!((g.color(true, 20.0), g.color(true, 200.0)), (None, Some([0xff, 0x00, 0xff])));
// Colour only: an uncoloured guide gets one; the position stays.
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "color": "#123456"})).unwrap();
assert_eq!(guides(&s).color(true, 10.0), Some([0x12, 0x34, 0x56]));
// Turning it: % is of the new orientation's extent, and the colour goes along.
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "newOrientation": "horizontal", "position": "25%"})).unwrap();
let g = guides(&s);
assert_eq!((g.vertical.clone(), g.horizontal.clone()), (vec![10.0, 30.0], vec![50.0]));
assert_eq!((g.color(true, 200.0), g.color(false, 50.0)), (None, Some([0xff, 0x00, 0xff])));
// One step each: undo restores the vertical guide with its colour.
s.undo();
let g = guides(&s);
assert_eq!((g.vertical.clone(), g.horizontal.is_empty()), (vec![10.0, 200.0, 30.0], true));
assert_eq!(g.color(true, 200.0), Some([0xff, 0x00, 0xff]));
}
#[test]
fn bad_guide_edits_are_refused_without_a_history_step() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10, "color": "green"})).unwrap();
let before = s.active().unwrap().doc.guides.clone();
for bad in [
json!({}),
json!({"orientation": "vertical"}),
json!({"orientation": "vertical", "index": 1}),
json!({"orientation": "horizontal", "index": 0}),
json!({"orientation": "vertical", "index": -1}),
json!({"orientation": "vertical", "index": "0"}),
json!({"orientation": "vertical", "index": u64::MAX}),
json!({"orientation": "diagonal", "index": 0}),
json!({"orientation": "vertical", "index": 0, "newOrientation": 3}),
json!({"orientation": "vertical", "index": 0, "position": "far"}),
json!({"orientation": "vertical", "index": 0, "position": 1e12}),
json!({"orientation": "vertical", "index": 0, "color": "puce"}),
] {
assert!(s.execute("view.editGuide", bad.clone()).is_err(), "{bad}");
}
assert_eq!(s.active().unwrap().doc.guides, before);
assert_eq!(s.active().unwrap().history.undo_label(), Some("New Guide"));
}
#[test]
fn bad_guide_positions_are_refused_without_a_guide() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap();
let undo_before = s.active().unwrap().history.can_undo();
for bad in [
json!("abc"),
json!("50 furlongs"),
json!(""),
json!("%"),
json!("NaN"),
json!("inf"),
json!("1e400"),
json!("2000000px"),
json!(1e300),
json!(true),
json!([1]),
] {
assert!(s.execute("view.newGuide", json!({"orientation": "vertical", "position": bad.clone()})).is_err(), "{bad}");
}
assert!(s.execute("view.newGuide", json!({"orientation": "vertical"})).is_err(), "no position");
let g = &s.active().unwrap().doc.guides;
assert!(g.vertical.is_empty() && g.horizontal.is_empty());
assert_eq!(s.active().unwrap().history.can_undo(), undo_before, "a refused guide records no history step");
}
/// Guides keep their exact place (#2918): 1 cm at 300 ppi through New Guide and .pcraft, and
/// a 1/32 px PSD position past 2^19 px, where f32 has no 1/32 steps left.
#[test]
fn guides_keep_sub_pixel_positions_through_pcraft_and_psd() {
let (cm, far) = (300.0 / 2.54, 600_000.031_25);
let mut s = session();
s.execute("view.newGuide", json!({"orientation": "horizontal", "position": cm})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": far})).unwrap();
fn wide(v: impl Into<f64>) -> f64 {
v.into()
}
let exact = |g: &photocraft_doc::Guides| (wide(g.horizontal[0]), wide(g.vertical[0]));
let doc = &s.active().unwrap().doc;
assert_eq!(exact(&doc.guides), (cm, far));
let pcraft = photocraft_format::save_to_bytes(doc, &Default::default()).unwrap();
assert_eq!(exact(&photocraft_format::load_from_bytes(&pcraft).unwrap().guides), (cm, far));
let psd = photocraft_io::export(doc, "psd", &Default::default()).unwrap().bytes;
let back = photocraft_io::import("g.psd", &psd).unwrap().document.guides;
assert_eq!(wide(back.vertical[0]), far, "1032 stores 1/32 px");
}
/// `lift` as it was before #4516: the whole area through one `f32` buffer. The oracle for
/// the tiled version.
fn lift_reference(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Clip {
let fmt = src.format();
let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true);
let area = match sel {
Some(m) => m.content_bounds().intersect(&canvas),
None => src.content_bounds().intersect(&canvas),
};
let mut out = Surface::new(with_alpha);
if area.is_empty() {
return Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None };
}
let px = lift_region(src, sel, area, with_alpha.channels()).unwrap();
out.try_write_region(area, &px).unwrap();
out.prune();
let bounds = out.content_bounds();
Clip { surface: out, bounds, layers: None, resolution: None }
}
fn noise(fmt: PixelFormat, r: Rect, seed: u32) -> Surface {
let mut s = Surface::new(fmt);
let n = r.width() as usize * r.height() as usize * fmt.channels();
let data: Vec<f32> = (0..n as u32)
.map(|i| {
let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b);
h ^= h >> 13;
h = h.wrapping_mul(0xc2b2_ae35);
(h >> 16) as f32 / 65535.0
})
.collect();
s.write_region(r, &data);
s
}
fn assert_same_clip(a: &Clip, b: &Clip, what: &str) {
assert_eq!(a.bounds, b.bounds, "{what}: bounds");
assert_eq!(a.surface.format(), b.surface.format(), "{what}: format");
let r = a.bounds.union(&b.bounds);
if !r.is_empty() {
let (pa, pb) = (a.surface.read_region(r), b.surface.read_region(r));
assert!(pa.iter().zip(&pb).all(|(x, y)| x.to_bits() == y.to_bits()), "{what}: pixels differ");
}
}
/// The tiled `lift` gives exactly the old result (#4516): with and without alpha, at every
/// depth, with no selection, Select All, a partial feathered selection and a selection off
/// the tile grid, and for content reaching past the canvas.
#[test]
fn tiled_lift_matches_the_whole_area_lift() {
use photocraft_color::{ColorMode, SampleType};
let canvas = Rect::new(0, 0, 700, 600);
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [true, false] {
let fmt = PixelFormat::new(ColorMode::Rgb, sample, alpha);
let src = noise(fmt, Rect::new(-40, -30, 760, 640), 7);
let mut all = Surface::new(PixelFormat::GRAY8);
all.fill_rect(canvas, &[1.0]);
let mut partial = Surface::new(PixelFormat::GRAY8);
partial.fill_rect(Rect::new(100, 50, 600, 550), &[1.0]);
partial.write_region(Rect::new(90, 40, 610, 50), &vec![0.5; 520 * 10]);
let off_grid = noise(PixelFormat::GRAY8, Rect::new(13, 270, 333, 290), 9);
for (name, sel) in [("no selection", None), ("select all", Some(&all)), ("partial", Some(&partial)), ("off grid", Some(&off_grid))] {
let what = format!("{sample:?} alpha {alpha} {name}");
let new = lift(&src, sel, canvas).unwrap();
assert_same_clip(&new, &lift_reference(&src, sel, canvas), &what);
}
}
}
}
/// Copy shares the layer's whole tiles instead of copying them.
#[test]
fn copy_shares_whole_tiles_with_the_layer() {
let src = noise(PixelFormat::RGBA8, Rect::new(0, 0, 1024, 768), 3);
let canvas = Rect::new(0, 0, 1024, 768);
let mut all = Surface::new(PixelFormat::GRAY8);
all.fill_rect(canvas, &[1.0]);
for sel in [None, Some(&all)] {
let clip = lift(&src, sel, canvas).unwrap();
let shared = src.tiles().filter(|(c, t)| clip.surface.tile(**c).is_some_and(|o| std::sync::Arc::ptr_eq(o, t))).count();
assert_eq!(shared, src.tile_count(), "every tile is shared");
}
}
}