1366 lines
67 KiB
Rust
1366 lines
67 KiB
Rust
//! 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::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>>,
|
||
}
|
||
|
||
/// 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. Pixel layers, and several selected
|
||
/// layers, still copy the active layer's pixels.
|
||
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::Smart(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Group(_)))
|
||
}
|
||
|
||
/// 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 doc = &d.doc;
|
||
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.layers.push(l.clone());
|
||
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) {
|
||
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.
|
||
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 });
|
||
}
|
||
// 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 n = with_alpha.channels();
|
||
let mut px = if fmt.alpha {
|
||
crate::allocation::read_region(src, area, "copying pixels")?
|
||
} else {
|
||
let opaque = crate::allocation::read_region(src, area, "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, area, "applying the selection to copied pixels")?;
|
||
let mk = m.format().channels().max(1);
|
||
for (p, a) in px.chunks_exact_mut(n).zip(mask.chunks_exact(mk)) {
|
||
p[n - 1] *= a[0];
|
||
}
|
||
}
|
||
out.try_write_region(area, &px).map_err(|e| EngineError::Other(format!("not enough memory while copying pixels ({e}); the document was not changed")))?;
|
||
out.prune();
|
||
let bounds = out.content_bounds();
|
||
Ok(Clip { surface: out, bounds, layers: None })
|
||
}
|
||
|
||
/// 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 layers = whole_layer(d, id);
|
||
match (d.doc.layer(id).and_then(Layer::surface), &layers) {
|
||
(Some(surf), _) => Clip { layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? },
|
||
// A group has no pixels of its own: the clipboard pixels are what it shows.
|
||
(None, Some(copies)) => {
|
||
let mut shown = (**copies).clone();
|
||
for l in &mut shown.layers {
|
||
l.visible = true;
|
||
}
|
||
Clip { layers, ..lift(&merged_surface(&shown), None, canvas)? }
|
||
}
|
||
(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;
|
||
s.clipboard = Some(clip);
|
||
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()]}))
|
||
}
|
||
|
||
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<()> {
|
||
let bg = crate::extra_cmds::is_background(doc.layer(id).ok_or(EngineError::NoLayer(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)
|
||
})?;
|
||
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();
|
||
let id = s.edit("Paste", |doc, active| {
|
||
doc.patterns.extend(patterns);
|
||
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(((canvas.x0 + canvas.x1) as f64 / 2.0, (canvas.y0 + canvas.y1) as f64 / 2.0));
|
||
let b = clip.bounds;
|
||
((cx - (b.x0 + b.x1) as f64 / 2.0).round() as i32, (cy - (b.y0 + b.y1) as f64 / 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 fmt = clip.surface.format();
|
||
let target = PixelFormat::new(fmt.mode, fmt.sample, true);
|
||
let moved = if b.x0 == 0 && b.y0 == 0 { clip.surface } else { photocraft_algo::resample::translate_surface(&clip.surface, -b.x0, -b.y0) };
|
||
let mut doc = Document::new("Untitled", photocraft_geom::Size::new(w, h), fmt.mode, fmt.sample);
|
||
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);
|
||
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 = base.name == "Background" && base.locks.transparency;
|
||
// 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)?;
|
||
s.execute("layer.duplicate", json!({"layer": id.0}))?;
|
||
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.
|
||
s.undo();
|
||
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)
|
||
}
|
||
|
||
/// 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 = p.get("position").and_then(Value::as_f64).map(|v| v as f32);
|
||
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 g = if vertical { &mut doc.guides.vertical } else { &mut doc.guides.horizontal };
|
||
match op {
|
||
"new" => g.push(pos.ok_or(EngineError::Other("missing `position`".into()))?),
|
||
"move" => {
|
||
let i = index.filter(|i| *i < g.len()).ok_or(EngineError::Other("no such guide".into()))?;
|
||
g[i] = pos.ok_or(EngineError::Other("missing `position`".into()))?;
|
||
}
|
||
"delete" => {
|
||
let i = index.filter(|i| *i < g.len()).ok_or(EngineError::Other("no such guide".into()))?;
|
||
g.remove(i);
|
||
}
|
||
_ => {
|
||
doc.guides.horizontal.clear();
|
||
doc.guides.vertical.clear();
|
||
}
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
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"), "{} (a targeted colour or alpha channel copies that channel, as grayscale)", can_copy, |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}"##, has_doc, |s, p| guide_cmd(
|
||
s, p, "new"
|
||
)),
|
||
spec!("view.moveGuide", "Move Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n,"position":px}"##, has_doc, |s, p| guide_cmd(
|
||
s, p, "move"
|
||
)),
|
||
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")),
|
||
]
|
||
}
|
||
|
||
#[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 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()
|
||
}
|
||
|
||
/// #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();
|
||
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);
|
||
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_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]);
|
||
}
|
||
|
||
/// #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 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!(crate::extra_cmds::is_background(&d.layers[0]), "{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);
|
||
}
|
||
|
||
/// #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");
|
||
}
|
||
|
||
/// 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");
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
}
|