mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 12:27:38 -06:00
Session::edit now reports empty damage when the step leaves every layer's pixels and the document's compositing fields untouched (same_pixels: a bounded walk over the layer tree comparing tiles by pointer), so a selection change no longer recomposites the whole canvas. Adds a lasso_bench example. Fixes #1773. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2132 lines
98 KiB
Rust
2132 lines
98 KiB
Rust
//! Multi-layer selection and the commands that act on it: ⌘/⇧-click selection, Select › All /
|
||
//! Deselect / Find Layers, Layer › Align and Distribute, Link Layers, Merge Layers, Group from
|
||
//! Layers, Arrange › Reverse, Lock Layers and Rename Layer.
|
||
//!
|
||
//! The selection itself lives in [`DocState::selected_layers`] (UI state, like the active layer):
|
||
//! changing it never adds a history step. Every structural change below is one history step.
|
||
|
||
use photocraft_color::PixelFormat;
|
||
use photocraft_doc::{Document, Layer, LayerContent, LayerId};
|
||
use photocraft_geom::Rect;
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::commands::{CommandSpec, int_i32};
|
||
use crate::{DocState, EngineError, Result, Session};
|
||
|
||
// ---------- selection state ----------
|
||
|
||
fn doc_state(s: &Session) -> std::result::Result<&DocState, String> {
|
||
s.active().ok_or_else(|| "no document open".into())
|
||
}
|
||
|
||
/// Selected layers of the active document, bottom-to-top.
|
||
pub fn selected(s: &Session) -> Vec<LayerId> {
|
||
s.active().map(DocState::selected_layers).unwrap_or_default()
|
||
}
|
||
|
||
/// Ids in `ids` that have no ancestor also in `ids` (a selected group already carries its
|
||
/// selected children), bottom-to-top.
|
||
pub(crate) fn top_level(doc: &Document, ids: &[LayerId]) -> Vec<LayerId> {
|
||
let walk = doc.walk();
|
||
let paths: Vec<_> = walk.iter().filter(|(_, _, l)| ids.contains(&l.id)).map(|(p, _, l)| (p.clone(), l.id)).collect();
|
||
paths.iter().filter(|(p, _)| !paths.iter().any(|(q, _)| q.len() < p.len() && p.starts_with(q))).map(|(_, id)| *id).collect()
|
||
}
|
||
|
||
/// Replace the layer selection without a history step (like [`Session::select_layer`]).
|
||
pub fn set_selection(s: &mut Session, ids: Vec<LayerId>, active: Option<LayerId>, anchor: Option<LayerId>) -> Result<()> {
|
||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||
if let Some(bad) = ids.iter().chain(active.iter()).find(|id| st.doc.layer(**id).is_none()) {
|
||
return Err(EngineError::NoLayer(*bad));
|
||
}
|
||
let active = active.or_else(|| ids.last().copied());
|
||
st.selected_layers = ids;
|
||
if let Some(a) = active
|
||
&& !st.selected_layers.contains(&a)
|
||
{
|
||
st.selected_layers.push(a);
|
||
}
|
||
st.active_layer = active;
|
||
st.layer_anchor = anchor.or(active);
|
||
crate::fix_selection(st);
|
||
// Selecting layers is not an edit: keep a clean document clean.
|
||
let clean = st.saved_revision == st.revision;
|
||
st.revision += 1;
|
||
if clean {
|
||
st.saved_revision = st.revision;
|
||
}
|
||
st.last_damage = Some(Rect::EMPTY);
|
||
Ok(())
|
||
}
|
||
|
||
/// After a structural edit, make `ids` the selection (dropping any that no longer exist), which
|
||
/// is also what the edit's history state targets.
|
||
pub(crate) fn reselect(s: &mut Session, ids: Vec<LayerId>, active: Option<LayerId>) {
|
||
if let Some(st) = s.active_mut() {
|
||
st.selected_layers = ids;
|
||
if let Some(a) = active {
|
||
st.active_layer = Some(a);
|
||
st.layer_anchor = Some(a);
|
||
}
|
||
crate::fix_selection(st);
|
||
let layers = st.layer_target();
|
||
st.history.set_current_layers(layers);
|
||
}
|
||
}
|
||
|
||
/// `layer.select`: `{"layer": id, "mode": "replace|toggle|range|add"}`.
|
||
pub fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let id = LayerId(p.get("layer").and_then(Value::as_u64).ok_or_else(|| bad("layer.select", "missing `layer`"))?);
|
||
let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace");
|
||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||
if st.doc.layer(id).is_none() {
|
||
return Err(EngineError::NoLayer(id));
|
||
}
|
||
let mut sel = st.selected_layers();
|
||
match mode {
|
||
"replace" => s.select_layer(id)?,
|
||
"add" => {
|
||
if !sel.contains(&id) {
|
||
sel.push(id);
|
||
}
|
||
set_selection(s, sel, Some(id), Some(id))?;
|
||
}
|
||
"toggle" => {
|
||
if sel.contains(&id) {
|
||
if sel.len() == 1 {
|
||
// Photoshop keeps at least the clicked layer targeted.
|
||
return Ok(json!({"selected": [id.0]}));
|
||
}
|
||
sel.retain(|x| *x != id);
|
||
let active = if st.active_layer == Some(id) { sel.last().copied() } else { st.active_layer };
|
||
set_selection(s, sel, active, active)?;
|
||
} else {
|
||
sel.push(id);
|
||
set_selection(s, sel, Some(id), Some(id))?;
|
||
}
|
||
}
|
||
"range" => {
|
||
let anchor = st.layer_anchor.filter(|a| st.doc.layer(*a).is_some()).or(st.active_layer).unwrap_or(id);
|
||
let order: Vec<LayerId> = st.doc.walk().into_iter().map(|(_, _, l)| l.id).collect();
|
||
let (a, b) = (order.iter().position(|x| *x == anchor).unwrap_or(0), order.iter().position(|x| *x == id).unwrap_or(0));
|
||
let range = order[a.min(b)..=a.max(b)].to_vec();
|
||
set_selection(s, range, Some(id), Some(anchor))?;
|
||
}
|
||
other => return Err(bad("layer.select", format!("unknown mode `{other}`"))),
|
||
}
|
||
Ok(json!({"selected": selected(s).iter().map(|l| l.0).collect::<Vec<_>>()}))
|
||
}
|
||
|
||
fn is_locked_background(l: &Layer) -> bool {
|
||
l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_))
|
||
}
|
||
|
||
fn select_all_layers(s: &mut Session) -> Result<Value> {
|
||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !is_locked_background(l)).map(|(_, _, l)| l.id).collect();
|
||
if ids.is_empty() {
|
||
return Err(EngineError::Other("there are no layers to select".into()));
|
||
}
|
||
let active = d.active_layer.filter(|a| ids.contains(a)).or(ids.last().copied());
|
||
let n = ids.len();
|
||
set_selection(s, ids, active, None)?;
|
||
Ok(json!({"selected": n}))
|
||
}
|
||
|
||
fn find_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let needle = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("select.findLayers", "missing `name`"))?.to_lowercase();
|
||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| l.name.to_lowercase().contains(&needle)).map(|(_, _, l)| l.id).collect();
|
||
if ids.is_empty() {
|
||
return Err(EngineError::Other(format!("no layer name contains \"{needle}\"")));
|
||
}
|
||
let out = ids.iter().map(|l| l.0).collect::<Vec<_>>();
|
||
set_selection(s, ids, None, None)?;
|
||
Ok(json!({"selected": out}))
|
||
}
|
||
|
||
fn select_linked(s: &mut Session) -> Result<Value> {
|
||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||
let groups = link_groups(&d.doc, &d.selected_layers());
|
||
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| l.link_group.is_some_and(|g| groups.contains(&g))).map(|(_, _, l)| l.id).collect();
|
||
let active = d.active_layer.filter(|a| ids.contains(a)).or(ids.last().copied());
|
||
let n = ids.len();
|
||
set_selection(s, ids, active, active)?;
|
||
Ok(json!({"selected": n}))
|
||
}
|
||
|
||
// ---------- enabled predicates ----------
|
||
|
||
fn has_layer(s: &Session) -> std::result::Result<(), String> {
|
||
if selected(s).is_empty() { Err("no layer selected".into()) } else { Ok(()) }
|
||
}
|
||
fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||
doc_state(s).map(|_| ())
|
||
}
|
||
fn two_plus(s: &Session) -> std::result::Result<(), String> {
|
||
if selected(s).len() >= 2 { Ok(()) } else { Err("select two or more layers".into()) }
|
||
}
|
||
fn can_align(s: &Session) -> std::result::Result<(), String> {
|
||
let d = doc_state(s)?;
|
||
match d.selected_layers().len() {
|
||
0 => Err("no layer selected".into()),
|
||
// Photoshop aligns a single layer to the canvas (or to an active selection); 2+ align to each other.
|
||
_ => Ok(()),
|
||
}
|
||
}
|
||
fn can_distribute(s: &Session) -> std::result::Result<(), String> {
|
||
if selected(s).len() >= 3 { Ok(()) } else { Err("select three or more layers to distribute".into()) }
|
||
}
|
||
fn can_link(s: &Session) -> std::result::Result<(), String> {
|
||
let d = doc_state(s)?;
|
||
let sel = d.selected_layers();
|
||
match sel.len() {
|
||
0 => Err("no layer selected".into()),
|
||
1 if d.doc.layer(sel[0]).is_none_or(|l| l.link_group.is_none()) => Err("select two or more layers to link".into()),
|
||
_ => Ok(()),
|
||
}
|
||
}
|
||
fn has_linked(s: &Session) -> std::result::Result<(), String> {
|
||
let d = doc_state(s)?;
|
||
if link_groups(&d.doc, &d.selected_layers()).is_empty() { Err("the selected layers are not linked".into()) } else { Ok(()) }
|
||
}
|
||
|
||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||
}
|
||
|
||
// ---------- moving layers ----------
|
||
|
||
fn link_groups(doc: &Document, ids: &[LayerId]) -> Vec<u64> {
|
||
let mut g: Vec<u64> = ids.iter().filter_map(|id| doc.layer(*id)?.link_group).collect();
|
||
g.sort_unstable();
|
||
g.dedup();
|
||
g
|
||
}
|
||
|
||
/// `ids` plus every layer linked to one of them.
|
||
pub(crate) fn with_links(doc: &Document, ids: &[LayerId]) -> Vec<LayerId> {
|
||
let groups = link_groups(doc, ids);
|
||
let mut out = ids.to_vec();
|
||
if !groups.is_empty() {
|
||
for (_, _, l) in doc.walk() {
|
||
if l.link_group.is_some_and(|g| groups.contains(&g)) && !out.contains(&l.id) {
|
||
out.push(l.id);
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Move layers by whole pixels the way the Move tool does: pixels, linked masks, type and
|
||
/// vector content (shapes, linked vector masks) all move together.
|
||
pub(crate) fn move_layers(doc: &mut Document, moves: &[(LayerId, i32, i32)]) -> Result<()> {
|
||
let snapshot = doc.clone();
|
||
for &(id, dx, dy) in moves {
|
||
if dx == 0 && dy == 0 {
|
||
continue;
|
||
}
|
||
let locks = doc.effective_locks(id);
|
||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||
if locks.position || locks.all {
|
||
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
|
||
}
|
||
crate::commands::translate_layer(&snapshot, l, dx, dy);
|
||
crate::vector_cmds::translate_vectors(&snapshot, l, dx as f64, dy as f64);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// `layer.translate`: the explicit layer, or every selected layer, plus their linked layers.
|
||
pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let dx = int_i32("layer.translate", p, "dx")?.unwrap_or(0);
|
||
let dy = int_i32("layer.translate", p, "dy")?.unwrap_or(0);
|
||
if dx == 0 && dy == 0 {
|
||
return Ok(Value::Null);
|
||
}
|
||
let roots = match p.get("layer").and_then(Value::as_u64) {
|
||
Some(id) => vec![LayerId(id)],
|
||
None => selected(s),
|
||
};
|
||
if roots.is_empty() {
|
||
return Err(EngineError::Other("no active layer".into()));
|
||
}
|
||
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||
let ids = s.edit("Move", |doc, _| {
|
||
let ids = move_targets(doc, &roots);
|
||
if ids.is_empty() {
|
||
return Err(EngineError::NoLayer(roots[0]));
|
||
}
|
||
let moves: Vec<_> = ids.iter().map(|&id| (id, dx, dy)).collect();
|
||
move_layers(doc, &moves)?;
|
||
// A board moved past the right or bottom edge grows the canvas, as a new one does (#1531).
|
||
if ids.iter().any(|id| doc.layer(*id).is_some_and(|l| l.artboard().is_some())) {
|
||
crate::artboard_cmds::fit_canvas(doc);
|
||
}
|
||
Ok(ids)
|
||
})?;
|
||
note_damage(s, &before, &ids);
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
/// The layers a move of `roots` takes along: their linked layers, without layers whose group
|
||
/// is also moving (the group carries them).
|
||
pub fn move_targets(doc: &Document, roots: &[LayerId]) -> Vec<LayerId> {
|
||
top_level(doc, &with_links(doc, roots))
|
||
}
|
||
|
||
/// `doc` with the layers `ids` ([`move_targets`]) moved by whole pixels, as `layer.translate`
|
||
/// leaves them: the Move tool's live preview while it drags. Type, shape and smart-object pixels
|
||
/// shift as they are instead of re-rendering (the commit re-renders them), so a drag frame costs
|
||
/// copying the moving pixels only.
|
||
pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Document> {
|
||
let mut out = doc.clone();
|
||
for &id in ids {
|
||
let locks = doc.effective_locks(id);
|
||
let l = out.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||
if locks.position || locks.all {
|
||
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
|
||
}
|
||
shift_shown(doc, l, dx, dy);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// `photocraft_algo::resample::translate_surface` with the content scan cached per tile (a Move
|
||
/// drag shifts the same layers every frame; scanning them each time cost more than the copy).
|
||
pub(crate) fn shift_surface(s: &photocraft_raster::Surface, dx: i32, dy: i32) -> photocraft_raster::Surface {
|
||
s.translated(dx, dy, photocraft_compose::bounds::content_bounds(s))
|
||
}
|
||
|
||
/// [`crate::commands::translate_layer`] plus the vector side, without re-rendering anything.
|
||
fn shift_shown(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
|
||
use self::shift_surface as translate_surface;
|
||
let a = photocraft_geom::Affine::translate(f64::from(dx), f64::from(dy));
|
||
if let Some(r) = &mut l.effects.reference {
|
||
*r = (r.0 + f64::from(dx), r.1 + f64::from(dy));
|
||
}
|
||
if let Some(m) = l.mask.as_mut().filter(|m| m.linked) {
|
||
m.surface = translate_surface(&m.surface, dx, dy);
|
||
}
|
||
if let Some(vm) = l.vector_mask.as_mut().filter(|v| v.linked) {
|
||
vm.path = vm.path.transform(&a);
|
||
}
|
||
match &mut l.content {
|
||
LayerContent::Raster(s) => *s = translate_surface(s, dx, dy),
|
||
LayerContent::Text(t) => {
|
||
t.transform = a.mul(&t.transform);
|
||
if let Some(c) = &mut t.cache {
|
||
*c = translate_surface(c, dx, dy);
|
||
}
|
||
}
|
||
LayerContent::Shape(sh) => {
|
||
// The rendered shape is cut at the canvas: one that reaches past it renders again.
|
||
let canvas = doc.bounds();
|
||
let inside = sh.cache.as_ref().is_some_and(|c| {
|
||
let b = photocraft_compose::bounds::content_bounds(c);
|
||
b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1)
|
||
});
|
||
crate::vector_cmds::transform_shape(sh, &a);
|
||
match &mut sh.cache {
|
||
Some(c) if inside => *c = translate_surface(c, dx, dy),
|
||
_ => crate::vector_cmds::refresh_shape(doc, sh),
|
||
}
|
||
}
|
||
LayerContent::Smart(sm) => crate::smart_cmds::shift_smart(sm, dx, dy),
|
||
LayerContent::Group(g) => {
|
||
if let Some(ab) = &mut g.artboard {
|
||
ab.rect = ab.rect.translate(dx, dy);
|
||
}
|
||
g.children.iter_mut().for_each(|c| shift_shown(doc, c, dx, dy));
|
||
}
|
||
LayerContent::Adjustment(_) | LayerContent::Fill(_) => {}
|
||
}
|
||
}
|
||
|
||
/// The document pixels an edit of the layers `ids` (moved, shown, hidden or restyled; contents
|
||
/// otherwise as they were) can have changed from `before` to `after`, or `None` for anywhere.
|
||
pub fn layers_damage(before: &Document, after: &Document, ids: &[LayerId]) -> Option<Rect> {
|
||
let mut out = Rect::EMPTY;
|
||
for d in [before, after] {
|
||
let canvas = d.bounds();
|
||
for id in ids {
|
||
// A layer gone from one side (never for a move) changes who knows what.
|
||
let b = photocraft_compose::change_bounds(d.layer(*id)?, canvas)?;
|
||
if !b.is_empty() {
|
||
out = if out.is_empty() { b } else { out.union(&b) };
|
||
}
|
||
}
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
/// Report [`layers_damage`] as the active document's last change, so the canvas recomposites
|
||
/// only that (grown by the effect reach around it) instead of everything.
|
||
pub(crate) fn note_damage(s: &mut Session, before: &Document, ids: &[LayerId]) {
|
||
let Some(st) = s.active_mut() else { return };
|
||
if before.size != st.doc.size {
|
||
return;
|
||
}
|
||
st.last_damage = layers_damage(before, &st.doc, ids);
|
||
}
|
||
|
||
/// Whether two surfaces hold identical pixels: same format and default pixel, and the same
|
||
/// copy-on-write tile pointers at the same coordinates. Both documents stay alive during the
|
||
/// comparison, so tile addresses cannot be reused under it.
|
||
fn surfaces_equal(a: &photocraft_raster::Surface, b: &photocraft_raster::Surface) -> bool {
|
||
a.format() == b.format()
|
||
&& a.default_pixel() == b.default_pixel()
|
||
&& a.tile_count() == b.tile_count()
|
||
&& a.tiles().zip(b.tiles()).all(|((ca, ta), (cb, tb))| ca == cb && std::sync::Arc::ptr_eq(ta, tb))
|
||
}
|
||
|
||
/// Byte blobs kept in `Arc`s (PSD raw blocks, embedded smart-object sources): pointer-identical
|
||
/// or content-equal (they are small compared to pixels, and undo steps rarely re-parse them).
|
||
fn blob_equal(x: &std::sync::Arc<Vec<u8>>, y: &std::sync::Arc<Vec<u8>>) -> bool {
|
||
std::sync::Arc::ptr_eq(x, y) || (x.len() == y.len() && x.as_slice() == y.as_slice())
|
||
}
|
||
|
||
fn blobs_equal(a: &Option<std::sync::Arc<Vec<u8>>>, b: &Option<std::sync::Arc<Vec<u8>>>) -> bool {
|
||
match (a, b) {
|
||
(None, None) => true,
|
||
(Some(x), Some(y)) => blob_equal(x, y),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Layer masks (also the smart-filter mask): scalars plus tile-pointer surface identity.
|
||
fn masks_equal(a: &Option<photocraft_doc::LayerMask>, b: &Option<photocraft_doc::LayerMask>) -> bool {
|
||
match (a, b) {
|
||
(None, None) => true,
|
||
(Some(x), Some(y)) => {
|
||
x.enabled == y.enabled && x.linked == y.linked && x.density == y.density && x.feather == y.feather && surfaces_equal(&x.surface, &y.surface)
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn texts_equal(a: &photocraft_doc::TextLayer, b: &photocraft_doc::TextLayer) -> bool {
|
||
a.text == b.text
|
||
&& a.font_family == b.font_family
|
||
&& a.size_pt == b.size_pt
|
||
&& a.color == b.color
|
||
&& a.transform == b.transform
|
||
&& a.runs == b.runs
|
||
&& a.paragraphs == b.paragraphs
|
||
&& a.shape == b.shape
|
||
&& a.orientation == b.orientation
|
||
&& a.antialias == b.antialias
|
||
&& a.warp == b.warp
|
||
&& match (&a.cache, &b.cache) {
|
||
(None, None) => true,
|
||
(Some(x), Some(y)) => surfaces_equal(x, y),
|
||
_ => false,
|
||
}
|
||
&& blobs_equal(&a.psd_raw, &b.psd_raw)
|
||
}
|
||
|
||
fn smarts_equal(a: &photocraft_doc::SmartObject, b: &photocraft_doc::SmartObject) -> bool {
|
||
a.filters_enabled == b.filters_enabled
|
||
&& a.transform == b.transform
|
||
&& a.smart_filters == b.smart_filters
|
||
&& a.stack_mode == b.stack_mode
|
||
&& a.perspective == b.perspective
|
||
&& a.warp == b.warp
|
||
&& match (&a.source, &b.source) {
|
||
(photocraft_doc::SmartSource::Embedded { file_name: x, bytes: xb }, photocraft_doc::SmartSource::Embedded { file_name: y, bytes: yb }) => {
|
||
x == y && blob_equal(xb, yb)
|
||
}
|
||
(photocraft_doc::SmartSource::Linked { path: x }, photocraft_doc::SmartSource::Linked { path: y }) => x == y,
|
||
_ => false,
|
||
}
|
||
&& match (&a.cache, &b.cache) {
|
||
(None, None) => true,
|
||
(Some(x), Some(y)) => surfaces_equal(x, y),
|
||
_ => false,
|
||
}
|
||
&& blobs_equal(&a.psd_raw, &b.psd_raw)
|
||
&& masks_equal(&a.filter_mask, &b.filter_mask)
|
||
}
|
||
|
||
/// Extra alpha/spot channels (the image itself in Multichannel mode).
|
||
fn alpha_channels_equal(a: &photocraft_doc::AlphaChannel, b: &photocraft_doc::AlphaChannel) -> bool {
|
||
a.name == b.name && a.spot == b.spot && a.color == b.color && a.opacity == b.opacity && a.indicates == b.indicates && surfaces_equal(&a.surface, &b.surface)
|
||
}
|
||
|
||
/// Whether two layers render to identical pixels in place. Everything the compositor reads is
|
||
/// compared structurally — no hashing, a hash collision would under-report damage; pixels are
|
||
/// identified by their copy-on-write tile pointers. Group children are deliberately *not*
|
||
/// compared here: the caller walks into them, so an edit inside a group damages only the
|
||
/// edited child's extent, not the whole group's. The destructure is exhaustive, so a new
|
||
/// `Layer` field fails to compile until it is classified: compared, or explicitly ignored
|
||
/// (name, locks, label, `psd_blocks`, `psd_id`, link group — none of them composite; video
|
||
/// bails out earlier in the walk).
|
||
fn layer_pixels_equal(a: &Layer, b: &Layer) -> bool {
|
||
let Layer {
|
||
id,
|
||
name: _,
|
||
visible,
|
||
locks: _,
|
||
blend,
|
||
opacity,
|
||
fill_opacity,
|
||
clipped,
|
||
mask,
|
||
vector_mask,
|
||
effects,
|
||
label: _,
|
||
content,
|
||
psd_blocks: _,
|
||
psd_id: _,
|
||
fill_cache,
|
||
link_group: _,
|
||
excluded_channels,
|
||
blend_if,
|
||
advanced,
|
||
video: _,
|
||
} = a;
|
||
*id == b.id
|
||
&& *visible == b.visible
|
||
&& *blend == b.blend
|
||
&& *opacity == b.opacity
|
||
&& *fill_opacity == b.fill_opacity
|
||
&& *clipped == b.clipped
|
||
&& masks_equal(mask, &b.mask)
|
||
&& *vector_mask == b.vector_mask
|
||
&& *effects == b.effects
|
||
&& *excluded_channels == b.excluded_channels
|
||
&& *blend_if == b.blend_if
|
||
&& *advanced == b.advanced
|
||
&& match (content, &b.content) {
|
||
(LayerContent::Raster(x), LayerContent::Raster(y)) => surfaces_equal(x, y),
|
||
// Children are walked separately; only the group's own clipping frame counts.
|
||
(LayerContent::Group(x), LayerContent::Group(y)) => x.artboard == y.artboard,
|
||
(LayerContent::Fill(x), LayerContent::Fill(y)) => x == y,
|
||
(LayerContent::Adjustment(x), LayerContent::Adjustment(y)) => x == y,
|
||
(LayerContent::Text(x), LayerContent::Text(y)) => texts_equal(x, y),
|
||
(LayerContent::Shape(x), LayerContent::Shape(y)) => x == y,
|
||
(LayerContent::Smart(x), LayerContent::Smart(y)) => smarts_equal(x, y),
|
||
_ => false,
|
||
}
|
||
&& match (fill_cache, &b.fill_cache) {
|
||
(None, None) => true,
|
||
(Some(x), Some(y)) => x.fill == y.fill && surfaces_equal(&x.surface, &y.surface),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Document-level fields the compositor reads: all must match for a bounded damage claim. The
|
||
/// destructure is exhaustive, so a new `Document` field fails to compile until it is classified
|
||
/// here — compared when it composites, or explicitly ignored (the pixel-irrelevant allowlist:
|
||
/// identity, name, selection, guides, paths, layer comps, annotations, automation state).
|
||
fn doc_pixels_equal(a: &Document, b: &Document) -> bool {
|
||
let Document {
|
||
id: _,
|
||
name: _,
|
||
size,
|
||
resolution_dpi,
|
||
mode,
|
||
depth,
|
||
icc_profile,
|
||
layers: _,
|
||
channels,
|
||
guides: _,
|
||
selection: _,
|
||
metadata: _,
|
||
global_light,
|
||
paths: _,
|
||
work_path: _,
|
||
clipping_path: _,
|
||
quick_mask,
|
||
patterns,
|
||
color_table,
|
||
duotone,
|
||
layer_comps: _,
|
||
last_applied_comp: _,
|
||
last_document_state: _,
|
||
measurement: _,
|
||
notes: _,
|
||
text_styles: _,
|
||
slices: _,
|
||
variables: _,
|
||
timeline: _,
|
||
} = a;
|
||
*size == b.size
|
||
&& resolution_dpi.to_bits() == b.resolution_dpi.to_bits()
|
||
&& *mode == b.mode
|
||
&& *depth == b.depth
|
||
&& icc_profile.as_deref() == b.icc_profile.as_deref()
|
||
&& channels.len() == b.channels.len()
|
||
&& channels.iter().zip(&b.channels).all(|(x, y)| alpha_channels_equal(x, y))
|
||
&& *global_light == b.global_light
|
||
&& match (quick_mask, &b.quick_mask) {
|
||
(None, None) => true,
|
||
(Some(x), Some(y)) => alpha_channels_equal(x, y),
|
||
_ => false,
|
||
}
|
||
&& *patterns == b.patterns
|
||
&& *color_table == b.color_table
|
||
&& *duotone == b.duotone
|
||
}
|
||
|
||
/// Whether `a` and `b` composite to the same pixels: they differ at most in fields the
|
||
/// compositor never reads (selection, guides, paths, names, …), by the same classification as
|
||
/// [`step_damage`] but without computing any bounds. Cheap: pixels compare by tile pointer.
|
||
/// Conservative (`false`) past a nesting depth no real document reaches.
|
||
pub fn same_pixels(a: &Document, b: &Document) -> bool {
|
||
fn walk(xs: &[Layer], ys: &[Layer], depth: usize) -> bool {
|
||
depth < 256
|
||
&& xs.len() == ys.len()
|
||
&& xs.iter().zip(ys).all(|(x, y)| {
|
||
x.video.is_none()
|
||
&& y.video.is_none()
|
||
&& layer_pixels_equal(x, y)
|
||
&& match (x.children(), y.children()) {
|
||
(Some(xc), Some(yc)) => walk(xc, yc, depth + 1),
|
||
(None, None) => true,
|
||
_ => false,
|
||
}
|
||
})
|
||
}
|
||
doc_pixels_equal(a, b) && walk(&a.layers, &b.layers, 0)
|
||
}
|
||
|
||
/// Pixels a single history step can have changed between `a` and `b` (the documents on either
|
||
/// side of an undo or redo): the union of the change bounds of every layer whose pixels
|
||
/// differ, so undoing a local edit recomposites only that area instead of the whole canvas.
|
||
/// `None` (composite everything, as before) when the step touched anything the layer walk
|
||
/// cannot bound: document-level fields the compositor reads (canvas size, mode, depth,
|
||
/// profile, global light, patterns, channels, palettes), the layer structure (order,
|
||
/// additions, removals, kind changes; adding or removing a layer that draws nothing, see
|
||
/// [`inert_at`], is no change), layer properties that reach outside their own bounds
|
||
/// (clipping, excluded channels — what `layer.setProps` already reports as unbounded), and
|
||
/// video layers.
|
||
pub fn step_damage(a: &Document, b: &Document) -> Option<Rect> {
|
||
if !doc_pixels_equal(a, b) {
|
||
return None;
|
||
}
|
||
fn walk(xs: &[Layer], ys: &[Layer], a: &Document, b: &Document, out: &mut Rect) -> bool {
|
||
// Pair the layers by id, stepping over layers only one side has when they draw nothing
|
||
// (a new empty layer or group, #1771): adding or deleting one changes no pixels.
|
||
let (mut i, mut j) = (0, 0);
|
||
loop {
|
||
let (x, y) = match (xs.get(i), ys.get(j)) {
|
||
(None, None) => return true,
|
||
(Some(x), Some(y)) if x.id == y.id => (x, y),
|
||
_ if inert_at(ys, j) => {
|
||
j += 1;
|
||
continue;
|
||
}
|
||
_ if inert_at(xs, i) => {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
_ => return false,
|
||
};
|
||
i += 1;
|
||
j += 1;
|
||
if x.video.is_some() || y.video.is_some() {
|
||
return false;
|
||
}
|
||
// Clipping and channel exclusion change how *other* layers render, outside this
|
||
// layer's own bounds; they have no bounded damage.
|
||
if x.clipped != y.clipped || x.excluded_channels != y.excluded_channels {
|
||
return false;
|
||
}
|
||
if !layer_pixels_equal(x, y) {
|
||
// Both sides' extents: a move changes pixels where it left and where it landed.
|
||
let (Some(bx), Some(by)) = (photocraft_compose::change_bounds(x, a.bounds()), photocraft_compose::change_bounds(y, b.bounds())) else {
|
||
return false;
|
||
};
|
||
*out = if out.is_empty() { bx.union(&by) } else { out.union(&bx).union(&by) };
|
||
}
|
||
match (x.children(), y.children()) {
|
||
(Some(xc), Some(yc)) => {
|
||
if !walk(xc, yc, a, b, out) {
|
||
return false;
|
||
}
|
||
}
|
||
(None, None) => {}
|
||
_ => return false,
|
||
}
|
||
}
|
||
}
|
||
let mut out = Rect::EMPTY;
|
||
if walk(&a.layers, &b.layers, a, b, &mut out) { Some(out) } else { None }
|
||
}
|
||
|
||
/// Whether `layers[k]` draws nothing wherever it sits, so adding or removing it leaves the
|
||
/// composite as it was: an empty pixel layer (no tiles, transparent default) or an empty group,
|
||
/// in a Normal or Pass Through blend with no mask, vector mask, effects or Advanced Blending.
|
||
/// A clipping base that would gain or lose the clipped layers above it is not inert.
|
||
fn inert_at(layers: &[Layer], k: usize) -> bool {
|
||
let Some(l) = layers.get(k) else { return false };
|
||
let empty = match &l.content {
|
||
LayerContent::Raster(s) => s.tile_count() == 0 && s.format().alpha && s.default_bytes().iter().all(|b| *b == 0),
|
||
LayerContent::Group(g) => g.children.is_empty() && g.artboard.is_none(),
|
||
_ => false,
|
||
};
|
||
empty
|
||
&& matches!(l.blend, photocraft_doc::BlendMode::Normal | photocraft_doc::BlendMode::PassThrough)
|
||
&& l.mask.is_none()
|
||
&& l.vector_mask.is_none()
|
||
&& !photocraft_compose::effects::has_effects(l)
|
||
&& l.fill_cache.is_none()
|
||
&& l.video.is_none()
|
||
&& l.advanced == photocraft_doc::AdvancedBlending::default()
|
||
&& (l.clipped || layers.get(k + 1).is_none_or(|above| !above.clipped))
|
||
}
|
||
|
||
/// Report that adding layer `id` to the active document changed no pixels when it draws
|
||
/// nothing ([`inert_at`]), so the canvas doesn't recomposite every layer for it (#1771). Looks
|
||
/// only at the new layer and its neighbour, never at other layers' tiles.
|
||
pub(crate) fn note_inert_insert(s: &mut Session, id: LayerId) {
|
||
let Some(st) = s.active_mut() else { return };
|
||
let Some(path) = st.doc.path_of(id) else { return };
|
||
let Some((&k, parent)) = path.split_last() else { return };
|
||
let siblings = if parent.is_empty() { Some(st.doc.layers.as_slice()) } else { st.doc.layer_at(parent).and_then(Layer::children) };
|
||
if siblings.is_some_and(|sib| inert_at(sib, k)) {
|
||
st.last_damage = Some(Rect::EMPTY);
|
||
}
|
||
}
|
||
|
||
/// Content bounds used by Align/Distribute: the layer's pixels (type and shape layers use their
|
||
/// rendered pixels), or the union of a group's children. Fill and adjustment layers have none.
|
||
pub fn layer_bounds(l: &Layer) -> Option<Rect> {
|
||
let r = match &l.content {
|
||
LayerContent::Group(g) => g.children.iter().filter_map(layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)),
|
||
_ => l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY),
|
||
};
|
||
(!r.is_empty()).then_some(r)
|
||
}
|
||
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
enum Edge {
|
||
Top,
|
||
VCenter,
|
||
Bottom,
|
||
Left,
|
||
HCenter,
|
||
Right,
|
||
}
|
||
|
||
impl Edge {
|
||
fn parse(k: &str) -> Option<Edge> {
|
||
Some(match k {
|
||
"topEdges" => Edge::Top,
|
||
"verticalCenters" => Edge::VCenter,
|
||
"bottomEdges" => Edge::Bottom,
|
||
"leftEdges" => Edge::Left,
|
||
"horizontalCenters" => Edge::HCenter,
|
||
"rightEdges" => Edge::Right,
|
||
_ => return None,
|
||
})
|
||
}
|
||
fn vertical(self) -> bool {
|
||
matches!(self, Edge::Top | Edge::VCenter | Edge::Bottom)
|
||
}
|
||
/// The edge's coordinate, doubled so centres stay integral.
|
||
fn value2(self, r: &Rect) -> i64 {
|
||
let (a, b) = if self.vertical() { (r.y0, r.y1) } else { (r.x0, r.x1) };
|
||
match self {
|
||
Edge::Top | Edge::Left => 2 * a as i64,
|
||
Edge::Bottom | Edge::Right => 2 * b as i64,
|
||
Edge::VCenter | Edge::HCenter => a as i64 + b as i64,
|
||
}
|
||
}
|
||
fn delta(self, d: i32) -> (i32, i32) {
|
||
if self.vertical() { (0, d) } else { (d, 0) }
|
||
}
|
||
}
|
||
|
||
/// Selected top-level layers that have content bounds, bottom-to-top.
|
||
fn bounded_selection(doc: &Document, sel: &[LayerId]) -> Vec<(LayerId, Rect)> {
|
||
top_level(doc, sel).into_iter().filter_map(|id| Some((id, layer_bounds(doc.layer(id)?)?))).collect()
|
||
}
|
||
|
||
fn align(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
|
||
let cmd = format!("layer.align.{kind}");
|
||
let edge = Edge::parse(kind).ok_or_else(|| bad(&cmd, "unknown edge"))?;
|
||
let sel = selected(s);
|
||
let to = p.get("to").and_then(Value::as_str).unwrap_or("auto").to_string();
|
||
let moved = s.edit("Align", |doc, _| {
|
||
let items = bounded_selection(doc, &sel);
|
||
if items.is_empty() {
|
||
return Err(EngineError::Other("the selected layers have no content to align".into()));
|
||
}
|
||
let target = match to.as_str() {
|
||
"canvas" => doc.bounds(),
|
||
"selection" => doc.selection.as_ref().map(|m| m.content_bounds()).ok_or_else(|| EngineError::Other("there is no selection to align to".into()))?,
|
||
"auto" | "layers" => match (&doc.selection, sel.len()) {
|
||
// Photoshop: one layer plus an active selection aligns to the selection bounds.
|
||
(Some(m), 1) if to == "auto" => m.content_bounds(),
|
||
// One layer with no selection aligns to the canvas (otherwise it would align to itself).
|
||
(None, 1) if to == "auto" => doc.bounds(),
|
||
_ => items.iter().fold(Rect::EMPTY, |a, (_, b)| a.union(b)),
|
||
},
|
||
other => return Err(bad(&cmd, format!("`to` must be auto|layers|selection|canvas, not `{other}`"))),
|
||
};
|
||
if target.is_empty() {
|
||
return Err(EngineError::Other("nothing to align to".into()));
|
||
}
|
||
let t2 = edge.value2(&target);
|
||
let moves: Vec<(LayerId, i32, i32)> = items
|
||
.iter()
|
||
.flat_map(|(id, r)| {
|
||
let d = (t2 - edge.value2(r)).div_euclid(2) as i32;
|
||
let (dx, dy) = edge.delta(d);
|
||
// Linked layers travel with the layer they are linked to.
|
||
with_links(doc, &[*id]).into_iter().map(move |l| (l, dx, dy))
|
||
})
|
||
.collect();
|
||
let moves = dedup_moves(doc, moves);
|
||
move_layers(doc, &moves)?;
|
||
Ok(moves.iter().filter(|m| m.1 != 0 || m.2 != 0).count())
|
||
})?;
|
||
Ok(json!({"moved": moved}))
|
||
}
|
||
|
||
/// One move per layer (first wins), skipping layers nested in another moved layer.
|
||
fn dedup_moves(doc: &Document, moves: Vec<(LayerId, i32, i32)>) -> Vec<(LayerId, i32, i32)> {
|
||
let mut out: Vec<(LayerId, i32, i32)> = Vec::new();
|
||
for m in moves {
|
||
if !out.iter().any(|o| o.0 == m.0) {
|
||
out.push(m);
|
||
}
|
||
}
|
||
let ids: Vec<LayerId> = out.iter().map(|m| m.0).collect();
|
||
let keep = top_level(doc, &ids);
|
||
out.retain(|m| keep.contains(&m.0));
|
||
out
|
||
}
|
||
|
||
fn distribute(s: &mut Session, kind: &str) -> Result<Value> {
|
||
let sel = selected(s);
|
||
let kind = kind.to_string();
|
||
let moved = s.edit("Distribute", |doc, _| {
|
||
let mut items = bounded_selection(doc, &sel);
|
||
if items.len() < 3 {
|
||
return Err(EngineError::Other("distributing needs three or more layers with content".into()));
|
||
}
|
||
let n = items.len();
|
||
let mut moves = Vec::new();
|
||
match kind.as_str() {
|
||
"horizontally" | "vertically" => {
|
||
// Equal gaps between neighbours; the outermost layers stay put.
|
||
let v = kind == "vertically";
|
||
let lo = |r: &Rect| if v { r.y0 } else { r.x0 } as i64;
|
||
let hi = |r: &Rect| if v { r.y1 } else { r.x1 } as i64;
|
||
items.sort_by_key(|(_, r)| (lo(r) + hi(r), lo(r)));
|
||
let span = hi(&items[n - 1].1) - lo(&items[0].1);
|
||
let total: i64 = items.iter().map(|(_, r)| hi(r) - lo(r)).sum();
|
||
let gap = (span - total) as f64 / (n - 1) as f64;
|
||
let mut cursor = lo(&items[0].1) as f64;
|
||
for (id, r) in &items {
|
||
let want = cursor.round() as i64;
|
||
let d = (want - lo(r)) as i32;
|
||
moves.push(if v { (*id, 0, d) } else { (*id, d, 0) });
|
||
cursor += (hi(r) - lo(r)) as f64 + gap;
|
||
}
|
||
}
|
||
k => {
|
||
let edge = Edge::parse(k).ok_or_else(|| bad("layer.distribute", format!("unknown kind `{k}`")))?;
|
||
items.sort_by_key(|(_, r)| edge.value2(r));
|
||
let (first, last) = (edge.value2(&items[0].1) as f64, edge.value2(&items[n - 1].1) as f64);
|
||
for (i, (id, r)) in items.iter().enumerate() {
|
||
let want2 = first + (last - first) * i as f64 / (n - 1) as f64;
|
||
let d = ((want2 - edge.value2(r) as f64) / 2.0).round() as i32;
|
||
let (dx, dy) = edge.delta(d);
|
||
moves.push((*id, dx, dy));
|
||
}
|
||
}
|
||
}
|
||
let moves: Vec<_> = moves.into_iter().flat_map(|(id, dx, dy)| with_links(doc, &[id]).into_iter().map(move |l| (l, dx, dy))).collect();
|
||
let moves = dedup_moves(doc, moves);
|
||
move_layers(doc, &moves)?;
|
||
Ok(moves.iter().filter(|m| m.1 != 0 || m.2 != 0).count())
|
||
})?;
|
||
Ok(json!({"moved": moved}))
|
||
}
|
||
|
||
/// Reorder one or multiple layers to a single destination in one undoable edit.
|
||
///
|
||
/// Explicit `layers` are ordered according to the source document, not caller ordering.
|
||
/// Selected descendants of a moved group remain inside their parent; only top-level
|
||
/// selected nodes are detached, and every reference is validated before editing.
|
||
pub fn move_to(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "layer.moveTo";
|
||
let target = LayerId(p.get("target").and_then(Value::as_u64).ok_or_else(|| bad(CMD, "missing `target`"))?);
|
||
let pos = p.get("position").and_then(Value::as_str).unwrap_or("above");
|
||
if !matches!(pos, "above" | "below" | "into") {
|
||
return Err(bad(CMD, "position must be above, below, or into"));
|
||
}
|
||
// ⌥-drag in the Layers panel: duplicates land at the target and the layers stay put.
|
||
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||
|
||
let batch = p.get("layers");
|
||
if batch.is_some() && p.get("layer").is_some() {
|
||
return Err(bad(CMD, "give either `layer` or `layers`, not both"));
|
||
}
|
||
let requested = if let Some(raw) = batch {
|
||
let arr = raw.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(CMD, "`layers` must be a nonempty array of ids"))?;
|
||
let mut ids = Vec::with_capacity(arr.len());
|
||
for item in arr {
|
||
let id = LayerId(item.as_u64().ok_or_else(|| bad(CMD, "every layer id must be an unsigned integer"))?);
|
||
if ids.contains(&id) {
|
||
return Err(bad(CMD, "duplicate layer id"));
|
||
}
|
||
ids.push(id);
|
||
}
|
||
ids
|
||
} else {
|
||
vec![crate::commands::layer_param(s, p)?]
|
||
};
|
||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||
let doc = &st.doc;
|
||
let target_path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?;
|
||
for &id in &requested {
|
||
if doc.layer(id).is_none() {
|
||
return Err(EngineError::NoLayer(id));
|
||
}
|
||
}
|
||
let moved_ids = top_level(doc, &requested);
|
||
if moved_ids.len() == 1 && moved_ids[0] == target && batch.is_none() && !copy {
|
||
// Preserve single-layer drag-to-self semantics.
|
||
return Ok(Value::Null);
|
||
}
|
||
// Also rejects dragging a selected group onto any of its descendants.
|
||
for id in &moved_ids {
|
||
let path = doc.path_of(*id).ok_or(EngineError::NoLayer(*id))?;
|
||
// A copy may land beside its own source (not inside it).
|
||
let beside_itself = copy && *id == target && pos != "into";
|
||
if target_path.starts_with(&path) && !beside_itself {
|
||
return Err(bad(CMD, "target cannot be one of the moved layers or its descendant"));
|
||
}
|
||
}
|
||
if pos == "into" && doc.layer(target).is_none_or(|l| !l.is_group()) {
|
||
return Err(bad(CMD, "target is not a group"));
|
||
}
|
||
|
||
let original_active = st.active_layer;
|
||
let (count, copies) = s.edit(if copy { "Duplicate Layer" } else { "Reorder Layers" }, |doc, active| {
|
||
let mut moved = Vec::with_capacity(moved_ids.len());
|
||
for &id in &moved_ids {
|
||
moved.push(if copy { crate::commands::layer_copy(doc, id)? } else { doc.remove(id).ok_or(EngineError::NoLayer(id))? });
|
||
}
|
||
let copies: Vec<LayerId> = if copy { moved.iter().map(|l| l.id).collect() } else { Vec::new() };
|
||
// Look up the destination AFTER removing every source: its indices may have shifted.
|
||
let path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?;
|
||
if pos == "into" {
|
||
let children = doc.layer_at_mut(&path).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target is not a group"))?;
|
||
children.extend(moved);
|
||
} else {
|
||
let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(target))?;
|
||
let siblings = if parent.is_empty() {
|
||
&mut doc.layers
|
||
} else {
|
||
doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target's parent is not a group"))?
|
||
};
|
||
let at = if pos == "below" { idx } else { idx + 1 };
|
||
siblings.splice(at..at, moved);
|
||
}
|
||
check_group_depth(doc, "Reorder Layer")?;
|
||
*active = if copy {
|
||
copies.last().copied()
|
||
} else if batch.is_some() {
|
||
original_active.filter(|id| requested.contains(id)).or_else(|| requested.last().copied())
|
||
} else {
|
||
moved_ids.first().copied()
|
||
};
|
||
Ok((moved_ids.len(), copies))
|
||
})?;
|
||
if copy {
|
||
let ids: Vec<u64> = copies.iter().map(|id| id.0).collect();
|
||
if batch.is_some() {
|
||
let active = copies.last().copied();
|
||
reselect(s, copies, active);
|
||
return Ok(json!({"moved": count, "layers": ids}));
|
||
}
|
||
return Ok(json!({"layer": ids.first()}));
|
||
}
|
||
if batch.is_some() {
|
||
let active = original_active.filter(|id| requested.contains(id)).or_else(|| requested.last().copied());
|
||
reselect(s, requested, active);
|
||
Ok(json!({"moved": count}))
|
||
} else {
|
||
Ok(Value::Null)
|
||
}
|
||
}
|
||
|
||
// ---------- structural commands ----------
|
||
|
||
fn link_layers(s: &mut Session) -> Result<Value> {
|
||
let sel = selected(s);
|
||
let linked = s.edit("Link Layers", |doc, _| {
|
||
let groups: Vec<Option<u64>> = sel.iter().map(|id| doc.layer(*id).and_then(|l| l.link_group)).collect();
|
||
// Already one link group (or a single linked layer): unlink, like Photoshop's toggle.
|
||
let first = groups.first().copied().flatten();
|
||
let unlink = first.is_some() && groups.iter().all(|g| *g == first);
|
||
// A loaded file can store any id up to u64::MAX, so the new id must not be a bare
|
||
// max + 1 (#961). Unlinking needs no new id at all.
|
||
let next = if unlink { None } else { Some(doc.unused_link_group().ok_or_else(|| bad("layer.linkLayers", "no free link group id is left"))?) };
|
||
for id in &sel {
|
||
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
|
||
l.link_group = next;
|
||
}
|
||
// A group left with a single member is no longer a link.
|
||
let mut counts = std::collections::HashMap::<u64, usize>::new();
|
||
for (_, _, l) in doc.walk() {
|
||
if let Some(g) = l.link_group {
|
||
*counts.entry(g).or_default() += 1;
|
||
}
|
||
}
|
||
let lonely: Vec<LayerId> =
|
||
doc.walk().iter().filter(|(_, _, l)| l.link_group.is_some_and(|g| counts.get(&g).is_some_and(|&c| c < 2))).map(|(_, _, l)| l.id).collect();
|
||
for id in lonely {
|
||
if let Some(l) = doc.layer_mut(id) {
|
||
l.link_group = None;
|
||
}
|
||
}
|
||
Ok(!unlink)
|
||
})?;
|
||
Ok(json!({"linked": linked}))
|
||
}
|
||
|
||
fn lock_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let sel = selected(s);
|
||
let keys = ["transparency", "pixels", "position", "artboard", "all"];
|
||
let given: Vec<(&str, bool)> = keys.iter().filter_map(|k| Some((*k, p.get(*k)?.as_bool()?))).collect();
|
||
s.edit("Lock Layers", |doc, _| {
|
||
// No explicit locks: toggle "lock all" (on unless every selected layer is already locked).
|
||
let all_locked = sel.iter().all(|id| doc.layer(*id).is_some_and(|l| l.locks.all));
|
||
let given: Vec<(&str, bool)> = if given.is_empty() { vec![("all", !all_locked)] } else { given.clone() };
|
||
for id in &sel {
|
||
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
|
||
for (k, v) in &given {
|
||
match *k {
|
||
"transparency" => l.locks.transparency = *v,
|
||
"pixels" => l.locks.pixels = *v,
|
||
"position" => l.locks.position = *v,
|
||
"artboard" => l.locks.artboard = *v,
|
||
_ => l.locks.all = *v,
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"layers": sel.len()}))
|
||
}
|
||
|
||
fn rename_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let name =
|
||
p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("layer.renameLayer", "missing `name`"))?.to_string();
|
||
let id = crate::commands::layer_param(s, p)?;
|
||
s.edit("Rename Layer", |doc, _| {
|
||
doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.name = name;
|
||
Ok(())
|
||
})?;
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
fn reverse(s: &mut Session) -> Result<Value> {
|
||
let sel = selected(s);
|
||
s.edit("Reverse", |doc, _| {
|
||
let ids = top_level(doc, &sel);
|
||
if ids.len() < 2 {
|
||
return Err(EngineError::Other("select two or more layers to reverse".into()));
|
||
}
|
||
let paths: Vec<Vec<usize>> = ids.iter().map(|id| doc.path_of(*id).ok_or(EngineError::NoLayer(*id))).collect::<Result<_>>()?;
|
||
let layers: Vec<Layer> = ids.iter().map(|id| doc.layer(*id).cloned().ok_or(EngineError::NoLayer(*id))).collect::<Result<_>>()?;
|
||
// Slots are disjoint (no layer is nested in another), so writing whole layers back to the
|
||
// same paths never shifts another slot.
|
||
for (path, l) in paths.iter().zip(layers.into_iter().rev()) {
|
||
*doc.layer_at_mut(path).ok_or_else(|| EngineError::Other("layer tree changed".into()))? = l;
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
/// Refuses an edit that left `doc` nested deeper than [`photocraft_doc::MAX_GROUP_DEPTH`]
|
||
/// groups. Called at the end of a `Session::edit` closure, so an `Err` leaves the document and
|
||
/// history untouched.
|
||
pub(crate) fn check_group_depth(doc: &Document, what: &str) -> Result<()> {
|
||
if doc.max_group_depth() > photocraft_doc::MAX_GROUP_DEPTH {
|
||
return Err(EngineError::Other(format!("{what} would nest layers deeper than {} groups", photocraft_doc::MAX_GROUP_DEPTH)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Group Layers (⌘G) / Group from Layers: the explicit layer, or every selected layer, moves
|
||
/// into a new group placed where the top-most of them was. Bottom-to-top order is preserved.
|
||
pub fn group_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = match p.get("layer").and_then(Value::as_u64) {
|
||
Some(id) => vec![LayerId(id)],
|
||
None => selected(s),
|
||
};
|
||
if ids.is_empty() {
|
||
return Err(EngineError::Other("no layer selected".into()));
|
||
}
|
||
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
|
||
let gid = s.edit("Group Layers", |doc, active| {
|
||
let ids = top_level(doc, &ids);
|
||
let top = *ids.last().ok_or(EngineError::NoLayer(LayerId(0)))?;
|
||
let name = name.unwrap_or_else(|| doc.next_layer_name("Group"));
|
||
let gid = doc.insert_above(Some(top), Layer::group(name, vec![]));
|
||
let mut children = Vec::with_capacity(ids.len());
|
||
for id in &ids {
|
||
children.push(doc.remove(*id).ok_or(EngineError::NoLayer(*id))?);
|
||
}
|
||
*doc.layer_mut(gid).and_then(Layer::children_mut).ok_or(EngineError::NoLayer(gid))? = children;
|
||
check_group_depth(doc, "Group Layers")?;
|
||
*active = Some(gid);
|
||
Ok(gid)
|
||
})?;
|
||
reselect(s, vec![gid], Some(gid));
|
||
Ok(json!({ "layer": gid.0 }))
|
||
}
|
||
|
||
fn merge_layers(s: &mut Session) -> Result<Value> {
|
||
let sel = selected(s);
|
||
if sel.len() < 2 {
|
||
return s.execute("layer.mergeDown", json!({}));
|
||
}
|
||
let mid = s.edit("Merge Layers", |doc, active| {
|
||
let ids = top_level(doc, &sel);
|
||
let top_id = *ids.last().ok_or_else(|| EngineError::Other("Merge Layers needs two or more layers".into()))?;
|
||
let top = doc.layer(top_id).ok_or(EngineError::NoLayer(top_id))?.clone();
|
||
let mut solo = doc.clone();
|
||
solo.layers = ids.iter().filter_map(|id| doc.layer(*id)).filter(|l| l.visible).cloned().collect();
|
||
if solo.layers.is_empty() {
|
||
return Err(EngineError::Other("the selected layers are all hidden".into()));
|
||
}
|
||
let buf = photocraft_compose::flatten(&solo);
|
||
let fmt = doc.pixel_format();
|
||
let fmt = PixelFormat::new(fmt.mode, fmt.sample, true);
|
||
let data: Vec<f32> = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect();
|
||
let mut merged = Layer::raster(top.name.clone(), fmt);
|
||
merged.locks = top.locks;
|
||
let surf = crate::pixels_mut(&mut merged)?;
|
||
surf.write_region(doc.bounds(), &data);
|
||
surf.prune();
|
||
let mid = merged.id;
|
||
// Hidden selected layers are discarded, as in Photoshop.
|
||
for id in &ids[..ids.len() - 1] {
|
||
doc.remove(*id);
|
||
}
|
||
*doc.layer_mut(top_id).ok_or(EngineError::NoLayer(top_id))? = merged;
|
||
*active = Some(mid);
|
||
Ok(mid)
|
||
})?;
|
||
reselect(s, vec![mid], Some(mid));
|
||
Ok(json!({ "layer": mid.0 }))
|
||
}
|
||
|
||
/// Delete Layer with several layers selected.
|
||
pub fn delete_selected(s: &mut Session) -> Result<Value> {
|
||
let sel = selected(s);
|
||
s.edit("Delete Layers", |doc, active| {
|
||
let ids = top_level(doc, &sel);
|
||
let below = ids.first().and_then(|id| {
|
||
let order: Vec<LayerId> = doc.walk().into_iter().map(|(_, _, l)| l.id).collect();
|
||
let i = order.iter().position(|x| x == id)?;
|
||
order[..i].iter().rev().find(|x| !sel.contains(x)).copied()
|
||
});
|
||
for id in &ids {
|
||
doc.remove(*id).ok_or(EngineError::NoLayer(*id))?;
|
||
}
|
||
if doc.layers.is_empty() {
|
||
return Err(EngineError::Other("a document must keep at least one layer".into()));
|
||
}
|
||
*active = below.filter(|b| doc.layer(*b).is_some()).or_else(|| doc.top_layer());
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"deleted": sel.len()}))
|
||
}
|
||
|
||
/// Duplicate Layer with several layers selected: each copy goes above its original and the
|
||
/// copies become the selection. Artboard copies go beside their boards unless `in_place`
|
||
/// ([`crate::artboard_cmds::place_copy`]).
|
||
pub fn duplicate_selected(s: &mut Session, in_place: bool) -> Result<Value> {
|
||
let sel = selected(s);
|
||
let old_active = s.active().and_then(|d| d.active_layer);
|
||
let (copies, active) = s.edit("Duplicate Layers", |doc, active| {
|
||
let mut copies = Vec::new();
|
||
let mut new_active = None;
|
||
for id in top_level(doc, &sel) {
|
||
let mut dup = doc.layer(id).ok_or(EngineError::NoLayer(id))?.duplicate();
|
||
dup.name = doc.copy_name(&dup.name);
|
||
let nid = doc.insert_above(Some(id), dup);
|
||
if !in_place {
|
||
crate::artboard_cmds::place_copy(doc, nid)?;
|
||
}
|
||
if Some(id) == old_active {
|
||
new_active = Some(nid);
|
||
}
|
||
copies.push(nid);
|
||
}
|
||
let a = new_active.or(copies.last().copied());
|
||
*active = a;
|
||
Ok((copies, a))
|
||
})?;
|
||
let out = copies.iter().map(|l| l.0).collect::<Vec<_>>();
|
||
reselect(s, copies, active);
|
||
Ok(json!({"layers": out}))
|
||
}
|
||
|
||
/// Show/hide every selected layer in one step.
|
||
pub fn set_visible_selected(s: &mut Session, visible: bool) -> Result<Value> {
|
||
let sel = selected(s);
|
||
s.edit(if visible { "Show Layers" } else { "Hide Layers" }, |doc, _| {
|
||
for id in &sel {
|
||
doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.visible = visible;
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
/// Is more than one layer selected (so single-layer commands should act on the whole set)?
|
||
pub fn multi(s: &Session, p: &Value) -> bool {
|
||
p.get("layer").is_none() && selected(s).len() > 1
|
||
}
|
||
|
||
// ---------- registry ----------
|
||
|
||
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 }
|
||
};
|
||
}
|
||
const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the selection bounds with one layer and an active selection, else the selected layers' bounds)"##;
|
||
vec![
|
||
spec!("select.allLayers", "All Layers", &["Select"], Some("Cmd+Alt+A"), "{}", has_doc, |s, _| select_all_layers(s)),
|
||
spec!("select.deselectLayers", "Deselect Layers", &["Select"], None, "{}", has_layer, |s, _| {
|
||
set_selection(s, Vec::new(), None, None)?;
|
||
if let Some(st) = s.active_mut() {
|
||
st.active_layer = None;
|
||
st.selected_layers.clear();
|
||
}
|
||
Ok(Value::Null)
|
||
}),
|
||
spec!("select.findLayers", "Find Layers", &["Select"], Some("Cmd+Alt+Shift+F"), r##"{"name":str (case-insensitive substring)}"##, has_doc, find_layers),
|
||
spec!("layer.selectLinkedLayers", "Select Linked Layers", &["Layer"], None, "{}", has_linked, |s, _| select_linked(s)),
|
||
spec!("layer.align.topEdges", "Top Edges", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "topEdges")),
|
||
spec!("layer.align.verticalCenters", "Vertical Centers", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "verticalCenters")),
|
||
spec!("layer.align.bottomEdges", "Bottom Edges", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "bottomEdges")),
|
||
spec!("layer.align.leftEdges", "Left Edges", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "leftEdges")),
|
||
spec!("layer.align.horizontalCenters", "Horizontal Centers", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "horizontalCenters")),
|
||
spec!("layer.align.rightEdges", "Right Edges", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "rightEdges")),
|
||
spec!("layer.distribute.topEdges", "Top Edges", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute(s, "topEdges")),
|
||
spec!("layer.distribute.verticalCenters", "Vertical Centers", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute(
|
||
s,
|
||
"verticalCenters"
|
||
)),
|
||
spec!("layer.distribute.bottomEdges", "Bottom Edges", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute(s, "bottomEdges")),
|
||
spec!("layer.distribute.leftEdges", "Left Edges", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute(s, "leftEdges")),
|
||
spec!("layer.distribute.horizontalCenters", "Horizontal Centers", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute(
|
||
s,
|
||
"horizontalCenters"
|
||
)),
|
||
spec!("layer.distribute.rightEdges", "Right Edges", &["Layer", "Distribute"], None, "{}", can_distribute, |s, _| distribute(s, "rightEdges")),
|
||
spec!(
|
||
"layer.distribute.horizontally",
|
||
"Horizontally",
|
||
&["Layer", "Distribute"],
|
||
None,
|
||
"{} (equal horizontal gaps)",
|
||
can_distribute,
|
||
|s, _| distribute(s, "horizontally")
|
||
),
|
||
spec!("layer.distribute.vertically", "Vertically", &["Layer", "Distribute"], None, "{} (equal vertical gaps)", can_distribute, |s, _| distribute(
|
||
s,
|
||
"vertically"
|
||
)),
|
||
spec!("layer.linkLayers", "Link Layers", &["Layer"], None, "{} (toggles: unlinks when the selection is already one link group)", can_link, |s, _| {
|
||
link_layers(s)
|
||
}),
|
||
spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)),
|
||
spec!("layer.new.groupFromLayers", "Group from Layers…", &["Layer", "New"], None, r##"{"name":str?}"##, has_layer, group_layers),
|
||
spec!("layer.arrange.reverse", "Reverse", &["Layer", "Arrange"], None, "{}", two_plus, |s, _| reverse(s)),
|
||
spec!(
|
||
"layer.lockLayers",
|
||
"Lock Layers…",
|
||
&["Layer"],
|
||
None,
|
||
r##"{"transparency":bool?,"pixels":bool?,"position":bool?,"artboard":bool?,"all":bool?} (none given: toggle lock all)"##,
|
||
has_layer,
|
||
lock_layers
|
||
),
|
||
spec!("layer.renameLayer", "Rename Layer", &["Layer"], None, r##"{"layer":id?,"name":str}"##, has_layer, rename_layer),
|
||
]
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn duplicates_are_numbered_not_stacked() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 20, "height": 20})).unwrap();
|
||
s.execute("layer.new.layer", json!({"name": "Layer 1"})).unwrap();
|
||
let name = |s: &Session| {
|
||
let st = s.active().unwrap();
|
||
st.doc.layer(st.active_layer.unwrap()).unwrap().name.clone()
|
||
};
|
||
s.execute("layer.duplicate", json!({})).unwrap();
|
||
assert_eq!(name(&s), "Layer 1 copy");
|
||
// Duplicating the copy (the active layer) numbers it rather than adding "copy" again.
|
||
s.execute("layer.duplicate", json!({})).unwrap();
|
||
assert_eq!(name(&s), "Layer 1 copy 2");
|
||
s.execute("layer.duplicate", json!({})).unwrap();
|
||
assert_eq!(name(&s), "Layer 1 copy 3");
|
||
let doc = &s.active().unwrap().doc;
|
||
assert_eq!(doc.copy_name("Layer 1"), "Layer 1 copy 4");
|
||
// Names that merely contain "copy" keep it.
|
||
assert_eq!(doc.copy_name("Copywriting"), "Copywriting copy");
|
||
assert_eq!(doc.copy_name("A copyedit"), "A copyedit copy");
|
||
}
|
||
|
||
fn session(depth: u32) -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 100, "height": 80, "depth": depth})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn doc(s: &Session) -> &Document {
|
||
&s.active().unwrap().doc
|
||
}
|
||
|
||
/// New pixel layer with an opaque red rectangle.
|
||
fn rect_layer(s: &mut Session, r: Rect) -> LayerId {
|
||
let id = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||
s.edit("paint", |doc, _| {
|
||
doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(r, &[1.0, 0.0, 0.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
id
|
||
}
|
||
|
||
fn bounds(s: &Session, id: LayerId) -> Rect {
|
||
layer_bounds(doc(s).layer(id).unwrap()).unwrap()
|
||
}
|
||
|
||
fn sel(s: &Session) -> Vec<LayerId> {
|
||
selected(s)
|
||
}
|
||
|
||
fn damage(s: &Session) -> Option<Rect> {
|
||
s.active().unwrap().last_damage
|
||
}
|
||
|
||
#[test]
|
||
fn moves_and_layer_props_report_only_their_area() {
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||
s.execute("layer.translate", json!({"dx": 5, "dy": -3})).unwrap();
|
||
assert_eq!(damage(&s), Some(Rect::new(10, 7, 25, 20)), "where the layer was and is");
|
||
s.execute("layer.setProps", json!({"layer": a.0, "visible": false})).unwrap();
|
||
assert_eq!(damage(&s), Some(Rect::new(15, 7, 25, 17)));
|
||
s.execute("layer.setProps", json!({"layer": a.0, "opacity": 0.5, "blend": "Multiply"})).unwrap();
|
||
assert_eq!(damage(&s), Some(Rect::new(15, 7, 25, 17)));
|
||
// Clipping changes reach the layers around: everything.
|
||
s.execute("layer.setProps", json!({"layer": a.0, "clipped": true})).unwrap();
|
||
assert_eq!(damage(&s), None);
|
||
// An adjustment layer changes everything beneath it.
|
||
let adj = s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||
s.execute("layer.setProps", json!({"layer": adj, "visible": false})).unwrap();
|
||
assert_eq!(damage(&s), None);
|
||
// The Background (opaque everywhere): everything.
|
||
let bg = doc(&s).layers[0].id;
|
||
s.execute("layer.setProps", json!({"layer": bg.0, "opacity": 0.5})).unwrap();
|
||
let all = doc(&s).bounds();
|
||
assert!(damage(&s).is_none_or(|r| r.contains_rect(&all)), "{:?}", damage(&s));
|
||
// Bad params still fail cleanly.
|
||
assert!(s.execute("layer.setProps", json!({"layer": 999_999, "visible": true})).is_err());
|
||
assert!(s.execute("layer.translate", json!({"layer": 999_999, "dx": 1})).is_err());
|
||
}
|
||
|
||
/// #1771: a new empty layer or group changes no pixels, so it (and undoing or redoing it)
|
||
/// must not make the canvas recomposite every layer of the document.
|
||
#[test]
|
||
fn new_empty_layers_and_groups_damage_nothing() {
|
||
for depth in [8, 16, 32] {
|
||
let mut s = session(depth);
|
||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||
let composite = |s: &Session| photocraft_compose::render(doc(s), doc(s).bounds());
|
||
let shown = composite(&s);
|
||
s.execute("layer.new.layer", json!({})).unwrap();
|
||
assert_eq!(damage(&s), Some(Rect::EMPTY), "{depth}-bit new layer");
|
||
assert_eq!(composite(&s), shown, "an empty layer changes no pixels");
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert_eq!(damage(&s), Some(Rect::EMPTY), "{depth}-bit undo New Layer");
|
||
s.execute("edit.redo", json!({})).unwrap();
|
||
assert_eq!(damage(&s), Some(Rect::EMPTY), "{depth}-bit redo New Layer");
|
||
s.execute("layer.new.group", json!({})).unwrap();
|
||
assert_eq!(damage(&s), Some(Rect::EMPTY), "{depth}-bit new group");
|
||
// Inside a group too.
|
||
let child = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||
let g = s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||
s.execute("layer.select", json!({"layer": child})).unwrap();
|
||
s.execute("layer.new.layer", json!({})).unwrap();
|
||
assert_eq!(damage(&s), Some(Rect::EMPTY), "{depth}-bit new layer in a group");
|
||
assert!(doc(&s).layer(LayerId(g)).and_then(Layer::children).is_some_and(|c| c.len() == 2));
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert_eq!(damage(&s), Some(Rect::EMPTY));
|
||
// Undoing a step that painted still reports that area, not nothing.
|
||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||
s.execute("layer.translate", json!({"dx": 5, "dy": 0})).unwrap();
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert_eq!(damage(&s), Some(Rect::new(10, 10, 25, 20)));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn new_layers_that_change_pixels_still_recomposite_everything() {
|
||
let mut s = session(8);
|
||
let base = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||
let clipped = rect_layer(&mut s, Rect::new(0, 0, 100, 80));
|
||
s.edit("green", |doc, _| {
|
||
doc.layer_mut(clipped).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 100, 80), &[0.0, 1.0, 0.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
s.execute("layer.setProps", json!({"layer": clipped.0, "clipped": true})).unwrap();
|
||
// A new layer between a clipping base and its clipped layer becomes their base.
|
||
s.execute("layer.select", json!({"layer": base.0})).unwrap();
|
||
let shown = photocraft_compose::render(doc(&s), doc(&s).bounds());
|
||
s.execute("layer.new.layer", json!({})).unwrap();
|
||
assert_eq!(damage(&s), None);
|
||
assert_ne!(photocraft_compose::render(doc(&s), doc(&s).bounds()), shown);
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert_eq!(damage(&s), None);
|
||
s.execute("edit.redo", json!({})).unwrap();
|
||
assert_eq!(damage(&s), None);
|
||
// A step that adds a layer with pixels (Layer via Copy, paste…) is not inert either.
|
||
let before = doc(&s).clone();
|
||
let mut after = before.clone();
|
||
let mut l = Layer::raster("painted", after.pixel_format());
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(1, 1, 2, 2), &[0.0, 1.0, 0.0, 1.0]);
|
||
after.insert_above(None, l);
|
||
assert_eq!(step_damage(&before, &after), None);
|
||
assert_eq!(step_damage(&after, &before), None);
|
||
// Nor, conservatively, an empty layer in another blend mode.
|
||
let mut styled = before.clone();
|
||
let mut l = Layer::raster("styled", styled.pixel_format());
|
||
l.blend = photocraft_doc::BlendMode::Multiply;
|
||
styled.insert_above(None, l);
|
||
assert_eq!(step_damage(&before, &styled), None);
|
||
// An empty layer in Normal mode is.
|
||
let mut plain = before.clone();
|
||
plain.insert_above(None, Layer::raster("plain", plain.pixel_format()));
|
||
assert_eq!(step_damage(&before, &plain), Some(Rect::EMPTY));
|
||
assert_eq!(step_damage(&plain, &before), Some(Rect::EMPTY));
|
||
}
|
||
|
||
#[test]
|
||
fn the_move_preview_matches_the_move() {
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||
let before = s.active().unwrap().doc.clone();
|
||
let ids = move_targets(&before, &[a]);
|
||
let shown = moved(&before, &ids, 7, 4).unwrap();
|
||
s.execute("layer.translate", json!({"layer": a.0, "dx": 7, "dy": 4})).unwrap();
|
||
assert_eq!(layer_bounds(shown.layer(a).unwrap()), Some(bounds(&s, a)));
|
||
// Locked layers don't move in the preview either.
|
||
let mut locked = (*before).clone();
|
||
locked.layer_mut(a).unwrap().locks.position = true;
|
||
assert!(moved(&locked, &ids, 1, 1).is_err());
|
||
assert!(moved(&before, &[LayerId(999_999)], 1, 1).is_err());
|
||
}
|
||
|
||
fn select_all(s: &mut Session, ids: &[LayerId]) {
|
||
s.execute("layer.select", json!({"layer": ids[0].0})).unwrap();
|
||
for id in &ids[1..] {
|
||
s.execute("layer.select", json!({"layer": id.0, "mode": "toggle"})).unwrap();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn toggle_and_range_selection() {
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
let b = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
let c = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
let d = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
assert_eq!(sel(&s), vec![d]);
|
||
s.execute("layer.select", json!({"layer": b.0, "mode": "toggle"})).unwrap();
|
||
assert_eq!(sel(&s), vec![b, d]);
|
||
assert_eq!(s.active().unwrap().active_layer, Some(b));
|
||
// Toggling the active layer off hands "active" to a remaining member.
|
||
s.execute("layer.select", json!({"layer": b.0, "mode": "toggle"})).unwrap();
|
||
assert_eq!(sel(&s), vec![d]);
|
||
assert_eq!(s.active().unwrap().active_layer, Some(d));
|
||
// The last selected layer can't be toggled off.
|
||
s.execute("layer.select", json!({"layer": d.0, "mode": "toggle"})).unwrap();
|
||
assert_eq!(sel(&s), vec![d]);
|
||
// Range from the anchor (a) to c.
|
||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||
s.execute("layer.select", json!({"layer": c.0, "mode": "range"})).unwrap();
|
||
assert_eq!(sel(&s), vec![a, b, c]);
|
||
// A second ⇧-click re-ranges from the same anchor.
|
||
s.execute("layer.select", json!({"layer": b.0, "mode": "range"})).unwrap();
|
||
assert_eq!(sel(&s), vec![a, b]);
|
||
assert!(s.execute("layer.select", json!({"layer": b.0, "mode": "bogus"})).is_err());
|
||
// Selection changes are not edits.
|
||
let history = s.active().unwrap().history.entries().len();
|
||
s.execute("select.allLayers", json!({})).unwrap();
|
||
assert_eq!(sel(&s), vec![a, b, c, d], "Select All Layers skips the locked Background");
|
||
s.execute("select.deselectLayers", json!({})).unwrap();
|
||
assert!(sel(&s).is_empty());
|
||
assert_eq!(s.active().unwrap().history.entries().len(), history);
|
||
assert!(!s.is_enabled("layer.align.topEdges"));
|
||
}
|
||
|
||
#[test]
|
||
fn find_layers_by_name() {
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
let b = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
s.execute("layer.renameLayer", json!({"layer": a.0, "name": "Sky photo"})).unwrap();
|
||
s.execute("layer.renameLayer", json!({"layer": b.0, "name": "Photo of sea"})).unwrap();
|
||
assert_eq!(doc(&s).layer(a).unwrap().name, "Sky photo");
|
||
let r = s.execute("select.findLayers", json!({"name": "PHOTO"})).unwrap();
|
||
assert_eq!(r["selected"], json!([a.0, b.0]));
|
||
assert!(s.execute("select.findLayers", json!({"name": "nothing"})).is_err());
|
||
assert!(s.execute("layer.renameLayer", json!({})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn align_each_edge() {
|
||
for depth in [8, 16, 32] {
|
||
let cases: [(&str, [Rect; 2]); 6] = [
|
||
("topEdges", [Rect::new(10, 5, 20, 15), Rect::new(40, 5, 50, 25)]),
|
||
("bottomEdges", [Rect::new(10, 20, 20, 30), Rect::new(40, 10, 50, 30)]),
|
||
// Union 5..30: centre 17.5; half-pixel offsets round towards the top.
|
||
("verticalCenters", [Rect::new(10, 12, 20, 22), Rect::new(40, 7, 50, 27)]),
|
||
("leftEdges", [Rect::new(10, 5, 20, 15), Rect::new(10, 10, 50, 30)]),
|
||
("rightEdges", [Rect::new(40, 5, 50, 15), Rect::new(10, 10, 50, 30)]),
|
||
("horizontalCenters", [Rect::new(25, 5, 35, 15), Rect::new(10, 10, 50, 30)]),
|
||
];
|
||
for (kind, want) in cases {
|
||
let mut s = session(depth);
|
||
let a = rect_layer(&mut s, Rect::new(10, 5, 20, 15));
|
||
let b = rect_layer(&mut s, Rect::new(40, 10, 50, 30));
|
||
// For the horizontal cases make b wide so it defines the target bounds.
|
||
if !matches!(kind, "topEdges" | "bottomEdges" | "verticalCenters") {
|
||
s.edit("wide", |doc, _| {
|
||
doc.layer_mut(b).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 50, 30), &[0.0, 1.0, 0.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
}
|
||
select_all(&mut s, &[a, b]);
|
||
s.execute(&format!("layer.align.{kind}"), json!({})).unwrap();
|
||
assert_eq!([bounds(&s, a), bounds(&s, b)], want, "{kind} at {depth}-bit");
|
||
// Pixels moved, not just bounds.
|
||
let r = want[0];
|
||
assert_eq!(doc(&s).layer(a).unwrap().surface().unwrap().pixel(r.x0, r.y0)[..4], [1.0, 0.0, 0.0, 1.0]);
|
||
// One undo restores the original positions.
|
||
s.undo();
|
||
assert_eq!(bounds(&s, a), Rect::new(10, 5, 20, 15));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn align_single_layer_to_selection() {
|
||
for depth in [8, 16, 32] {
|
||
let mut s = session(depth);
|
||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||
assert!(s.is_enabled("layer.align.leftEdges"), "one layer aligns to the canvas");
|
||
s.execute("select.rect", json!({"x": 50, "y": 40, "width": 30, "height": 30})).unwrap();
|
||
assert!(s.is_enabled("layer.align.leftEdges"));
|
||
s.execute("layer.align.rightEdges", json!({})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(70, 10, 80, 20));
|
||
s.execute("layer.align.verticalCenters", json!({})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(70, 50, 80, 60));
|
||
s.execute("layer.align.topEdges", json!({"to": "canvas"})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(70, 0, 80, 10));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn align_single_layer_to_canvas() {
|
||
// Photoshop: a single selected layer with no active selection aligns to the canvas.
|
||
let mut s = session(8); // 100 x 80
|
||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20)); // 10 x 10
|
||
assert!(s.is_enabled("layer.align.horizontalCenters"));
|
||
s.execute("layer.align.horizontalCenters", json!({})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(45, 10, 55, 20), "centred on the 100px-wide canvas");
|
||
s.execute("layer.align.verticalCenters", json!({})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(45, 35, 55, 45), "centred on the 80px-tall canvas");
|
||
s.execute("layer.align.leftEdges", json!({})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(0, 35, 10, 45), "left edge to the canvas");
|
||
}
|
||
|
||
#[test]
|
||
fn distribute_uneven_layers() {
|
||
for depth in [8, 16, 32] {
|
||
let mut s = session(depth);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 10, 10));
|
||
let b = rect_layer(&mut s, Rect::new(12, 0, 32, 10)); // 20 wide
|
||
let c = rect_layer(&mut s, Rect::new(70, 0, 100, 10)); // 30 wide
|
||
select_all(&mut s, &[a, b, c]);
|
||
// Undo targets the layers its state had when it was created (#495): reselect.
|
||
assert!(s.is_enabled("layer.distribute.leftEdges"));
|
||
s.execute("layer.distribute.leftEdges", json!({})).unwrap();
|
||
assert_eq!(bounds(&s, b), Rect::new(35, 0, 55, 10), "left edges 0, 35, 70");
|
||
s.undo();
|
||
select_all(&mut s, &[a, b, c]);
|
||
s.execute("layer.distribute.horizontalCenters", json!({})).unwrap();
|
||
// centres 5 and 85 → middle centre 45
|
||
assert_eq!(bounds(&s, b), Rect::new(35, 0, 55, 10));
|
||
s.undo();
|
||
select_all(&mut s, &[a, b, c]);
|
||
s.execute("layer.distribute.rightEdges", json!({})).unwrap();
|
||
// right edges 10 and 100 → 55
|
||
assert_eq!(bounds(&s, b), Rect::new(35, 0, 55, 10));
|
||
s.undo();
|
||
select_all(&mut s, &[a, b, c]);
|
||
s.execute("layer.distribute.horizontally", json!({})).unwrap();
|
||
// span 100, widths 60 → gaps of 20: b at 30..50
|
||
assert_eq!(bounds(&s, b), Rect::new(30, 0, 50, 10));
|
||
assert_eq!(bounds(&s, a), Rect::new(0, 0, 10, 10));
|
||
assert_eq!(bounds(&s, c), Rect::new(70, 0, 100, 10));
|
||
s.undo();
|
||
assert_eq!(bounds(&s, b), Rect::new(12, 0, 32, 10));
|
||
}
|
||
// Vertical: tall layers with uneven heights.
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 10, 10));
|
||
let b = rect_layer(&mut s, Rect::new(0, 50, 10, 54));
|
||
let c = rect_layer(&mut s, Rect::new(0, 60, 10, 80));
|
||
select_all(&mut s, &[a, b, c]);
|
||
s.execute("layer.distribute.vertically", json!({})).unwrap();
|
||
// span 80, heights 34 → gaps 23: b at 33..37
|
||
assert_eq!(bounds(&s, b), Rect::new(0, 33, 10, 37));
|
||
s.undo();
|
||
select_all(&mut s, &[a, b, c]);
|
||
s.execute("layer.distribute.topEdges", json!({})).unwrap();
|
||
assert_eq!(bounds(&s, b), Rect::new(0, 30, 10, 34));
|
||
s.undo();
|
||
select_all(&mut s, &[a, b, c]);
|
||
s.execute("layer.distribute.bottomEdges", json!({})).unwrap();
|
||
assert_eq!(bounds(&s, b), Rect::new(0, 41, 10, 45));
|
||
s.undo();
|
||
select_all(&mut s, &[a, b, c]);
|
||
s.execute("layer.distribute.verticalCenters", json!({})).unwrap();
|
||
// centres 5 and 70 → 37.5, b is 4 tall → top 35.5 → rounds to 36 (35 for ties down)
|
||
let y0 = bounds(&s, b).y0;
|
||
assert!((35..=36).contains(&y0), "{y0}");
|
||
// Two layers can't be distributed.
|
||
select_all(&mut s, &[a, b]);
|
||
assert!(!s.is_enabled("layer.distribute.leftEdges"));
|
||
}
|
||
|
||
#[test]
|
||
fn merge_selected_into_top_most() {
|
||
for depth in [8, 16, 32] {
|
||
let mut s = session(depth);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 10, 10));
|
||
let b = rect_layer(&mut s, Rect::new(20, 0, 30, 10));
|
||
let c = rect_layer(&mut s, Rect::new(40, 0, 50, 10));
|
||
s.execute("layer.renameLayer", json!({"layer": c.0, "name": "Top"})).unwrap();
|
||
select_all(&mut s, &[a, c]);
|
||
let before = doc(&s).layer_count();
|
||
let m = LayerId(s.execute("layer.mergeLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||
let d = doc(&s);
|
||
assert_eq!(d.layer_count(), before - 1);
|
||
let ml = d.layer(m).unwrap();
|
||
assert_eq!(ml.name, "Top");
|
||
assert_eq!(layer_bounds(ml).unwrap(), Rect::new(0, 0, 50, 10));
|
||
assert_eq!(ml.surface().unwrap().pixel(5, 5)[..4], [1.0, 0.0, 0.0, 1.0]);
|
||
assert_eq!(ml.surface().unwrap().pixel(25, 5)[3], 0.0, "b was not merged");
|
||
assert!(d.layer(b).is_some() && d.layer(a).is_none());
|
||
assert_eq!(d.layers.last().unwrap().id, m, "merged layer sits where the top-most was");
|
||
assert_eq!(sel(&s), vec![m]);
|
||
s.undo();
|
||
assert_eq!(doc(&s).layer_count(), before);
|
||
assert_eq!(sel(&s), vec![a, c], "undo selects the layers that were merged");
|
||
// One layer selected: Merge Down.
|
||
s.execute("layer.select", json!({"layer": c.0})).unwrap();
|
||
s.execute("layer.mergeLayers", json!({})).unwrap();
|
||
assert_eq!(doc(&s).layer_count(), before - 1);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_locked_group_locks_its_contents() {
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
let g = s.execute("layer.new.groupFromLayers", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||
s.execute("layer.setProps", json!({"layer": g, "locked": true})).unwrap();
|
||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||
for (cmd, p) in [
|
||
("layer.translate", json!({"dx": 1})),
|
||
("paint.stroke", json!({"points": [[2, 2], [4, 4]]})),
|
||
("edit.fill", json!({"color": "#00ff00"})),
|
||
("edit.transform.flipHorizontal", json!({})),
|
||
] {
|
||
assert!(s.execute(cmd, p).is_err(), "{cmd} edited a layer in a locked group");
|
||
}
|
||
// Unlocking the group releases its contents.
|
||
s.execute("layer.setProps", json!({"layer": g, "locked": false})).unwrap();
|
||
s.execute("layer.translate", json!({"dx": 1})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(1, 0, 6, 5));
|
||
}
|
||
|
||
#[test]
|
||
fn a_locked_group_keeps_a_grouped_background_locked() {
|
||
let mut s = session(8);
|
||
let bg = doc(&s).layers[0].id;
|
||
s.execute("layer.select", json!({"layer": bg.0})).unwrap();
|
||
let g = s.execute("layer.new.groupFromLayers", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||
s.execute("layer.setProps", json!({"layer": g, "locked": true})).unwrap();
|
||
s.execute("layer.select", json!({"layer": bg.0})).unwrap();
|
||
for (cmd, p) in [
|
||
("edit.transform.flipHorizontal", json!({})),
|
||
("edit.transform.warp", json!({"style": "flag", "bend": 50})),
|
||
("edit.puppetWarp", json!({"pins": [{"src": [30, 25], "dst": [36, 29]}]})),
|
||
] {
|
||
let e = s.execute(cmd, p).unwrap_err().to_string();
|
||
assert!(e.contains("locked"), "{cmd}: {e}");
|
||
assert_eq!(doc(&s).layer(bg).unwrap().name, "Background", "{cmd}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn group_from_layers_preserves_order() {
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
let b = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
let c = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
let d = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
select_all(&mut s, &[c, a]);
|
||
let g = LayerId(s.execute("layer.new.groupFromLayers", json!({"name": "Pair"})).unwrap()["layer"].as_u64().unwrap());
|
||
let docu = doc(&s);
|
||
let gl = docu.layer(g).unwrap();
|
||
assert_eq!(gl.name, "Pair");
|
||
assert_eq!(gl.children().unwrap().iter().map(|l| l.id).collect::<Vec<_>>(), vec![a, c]);
|
||
// Root (bottom-to-top): Background, b, Pair (where c was), d.
|
||
assert_eq!(docu.layers.iter().map(|l| l.id).skip(1).collect::<Vec<_>>(), vec![b, g, d]);
|
||
assert_eq!(sel(&s), vec![g]);
|
||
s.undo();
|
||
assert_eq!(doc(&s).layers.len(), 5);
|
||
// ⌘G with several selected groups them too.
|
||
select_all(&mut s, &[b, d]);
|
||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||
assert_eq!(doc(&s).layers.len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn grouping_is_capped_at_the_document_nesting_limit() {
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
select_all(&mut s, &[a]);
|
||
for _ in 0..photocraft_doc::MAX_GROUP_DEPTH {
|
||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||
}
|
||
assert_eq!(doc(&s).max_group_depth(), photocraft_doc::MAX_GROUP_DEPTH);
|
||
// One more wrapping group would pass the cap: rejected, document untouched.
|
||
let err = s.execute("layer.groupLayers", json!({})).unwrap_err();
|
||
assert!(err.to_string().contains("deeper than 100"), "{err}");
|
||
assert_eq!(doc(&s).max_group_depth(), photocraft_doc::MAX_GROUP_DEPTH);
|
||
// The rejected call recorded no history step: undo still lands one grouping earlier.
|
||
s.undo();
|
||
assert_eq!(doc(&s).max_group_depth(), photocraft_doc::MAX_GROUP_DEPTH - 1);
|
||
}
|
||
|
||
#[test]
|
||
fn select_linked_layers_excludes_an_unlinked_active_layer() {
|
||
for depth in [8, 16, 32] {
|
||
let mut s = session(depth);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 10, 10));
|
||
let b = rect_layer(&mut s, Rect::new(20, 20, 30, 30));
|
||
let other = rect_layer(&mut s, Rect::new(50, 50, 60, 60));
|
||
select_all(&mut s, &[a, b]);
|
||
s.execute("layer.linkLayers", json!({})).unwrap();
|
||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||
s.execute("layer.select", json!({"layer": other.0, "mode": "toggle"})).unwrap();
|
||
assert_eq!(sel(&s), vec![a, other]);
|
||
assert_eq!(s.active().unwrap().active_layer, Some(other));
|
||
let past = s.active().unwrap().history.past_len();
|
||
let dirty = s.active().unwrap().is_dirty();
|
||
|
||
let result = s.execute("layer.selectLinkedLayers", json!({})).unwrap();
|
||
assert_eq!(sel(&s), vec![a, b]);
|
||
assert_eq!(result["selected"], sel(&s).len());
|
||
assert_eq!(s.active().unwrap().active_layer, Some(b));
|
||
assert_eq!(s.active().unwrap().layer_anchor, Some(b));
|
||
assert_eq!(s.active().unwrap().history.past_len(), past);
|
||
assert_eq!(s.active().unwrap().is_dirty(), dirty);
|
||
|
||
// A following multi-layer delete must leave the unrelated layer intact.
|
||
s.execute("layer.delete", json!({})).unwrap();
|
||
assert!(doc(&s).layer(a).is_none() && doc(&s).layer(b).is_none());
|
||
assert!(doc(&s).layer(other).is_some());
|
||
s.undo();
|
||
assert!(doc(&s).layer(a).is_some() && doc(&s).layer(b).is_some());
|
||
assert!(doc(&s).layer(other).is_some());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn select_linked_layers_preserves_a_linked_active_layer() {
|
||
for depth in [8, 16, 32] {
|
||
let mut s = session(depth);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 10, 10));
|
||
let b = rect_layer(&mut s, Rect::new(20, 20, 30, 30));
|
||
select_all(&mut s, &[a, b]);
|
||
s.execute("layer.linkLayers", json!({})).unwrap();
|
||
for active in [a, b] {
|
||
s.execute("layer.select", json!({"layer": active.0})).unwrap();
|
||
let st = s.active_mut().unwrap();
|
||
st.saved_revision = st.revision;
|
||
let past = st.history.past_len();
|
||
for _ in 0..2 {
|
||
let result = s.execute("layer.selectLinkedLayers", json!({})).unwrap();
|
||
assert_eq!(sel(&s), vec![a, b]);
|
||
assert_eq!(result["selected"], sel(&s).len());
|
||
assert_eq!(s.active().unwrap().active_layer, Some(active));
|
||
assert_eq!(s.active().unwrap().layer_anchor, Some(active));
|
||
assert_eq!(s.active().unwrap().history.past_len(), past);
|
||
assert!(!s.active().unwrap().is_dirty());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn select_linked_layers_requires_a_linked_selection() {
|
||
let mut s = Session::new();
|
||
assert!(!s.is_enabled("layer.selectLinkedLayers"));
|
||
assert!(s.execute("layer.selectLinkedLayers", json!({})).is_err());
|
||
let mut s = session(8);
|
||
let before = sel(&s);
|
||
assert!(!s.is_enabled("layer.selectLinkedLayers"));
|
||
assert!(s.execute("layer.selectLinkedLayers", json!({})).is_err());
|
||
assert_eq!(sel(&s), before);
|
||
}
|
||
|
||
#[test]
|
||
fn translate_rejects_offsets_that_would_wrap() {
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
select_all(&mut s, &[a]);
|
||
// 2^32 + 50 wrapped to `dx = 50` and 3e9 to a negative offset through `as i32`.
|
||
for dx in [4_294_967_346_i64, 3_000_000_000_i64] {
|
||
let err = s.execute("layer.translate", json!({"dx": dx, "dy": 0})).unwrap_err();
|
||
assert!(err.to_string().contains("32-bit"), "{err}");
|
||
}
|
||
assert_eq!(bounds(&s, a), Rect::new(0, 0, 5, 5), "the layer never moved");
|
||
// Large in-range offsets still work.
|
||
s.execute("layer.translate", json!({"dx": -200_000, "dy": 200_000})).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn link_layers_never_reuses_a_group_when_the_largest_id_is_u64_max() {
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 10, 10));
|
||
let b = rect_layer(&mut s, Rect::new(20, 20, 30, 30));
|
||
let c = rect_layer(&mut s, Rect::new(50, 50, 60, 60));
|
||
let d = rect_layer(&mut s, Rect::new(70, 70, 80, 80));
|
||
// A stored document can carry any id; `max + 1` once overflowed here.
|
||
let st = s.active_mut().unwrap();
|
||
for id in [c, d] {
|
||
std::sync::Arc::make_mut(&mut st.doc).layer_mut(id).unwrap().link_group = Some(u64::MAX);
|
||
}
|
||
select_all(&mut s, &[a, b]);
|
||
assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true);
|
||
let g = doc(&s).layer(a).unwrap().link_group;
|
||
assert_eq!(g, Some(1));
|
||
assert_eq!(doc(&s).layer(b).unwrap().link_group, g);
|
||
for id in [c, d] {
|
||
assert_eq!(doc(&s).layer(id).unwrap().link_group, Some(u64::MAX), "the existing group is untouched");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn undo_and_redo_report_only_the_edited_area() {
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||
select_all(&mut s, &[a]);
|
||
// A pixel edit on one layer is bounded by that layer's extent on both sides.
|
||
s.edit("paint", |doc, _| {
|
||
doc.layer_mut(a).unwrap().surface_mut().unwrap().fill_rect(Rect::new(12, 12, 14, 14), &[0.0, 0.0, 1.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
s.undo();
|
||
assert_eq!(damage(&s), Some(Rect::new(10, 10, 20, 20)));
|
||
s.redo();
|
||
assert_eq!(damage(&s), Some(Rect::new(10, 10, 20, 20)));
|
||
// A move reports where the layer was and where it landed.
|
||
s.execute("layer.translate", json!({"dx": 5, "dy": 0})).unwrap();
|
||
s.undo();
|
||
assert_eq!(damage(&s), Some(Rect::new(10, 10, 25, 20)), "union of where the layer was and is");
|
||
s.redo();
|
||
assert_eq!(damage(&s), Some(Rect::new(10, 10, 25, 20)));
|
||
}
|
||
|
||
#[test]
|
||
fn undo_of_document_wide_steps_still_composites_everything() {
|
||
let mut s = session(8);
|
||
let _ = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||
// A canvas change has no bounded damage.
|
||
s.execute("image.crop", json!({"x": 0, "y": 0, "width": 30, "height": 25})).unwrap();
|
||
assert_ne!(doc(&s).size, photocraft_doc::Size::new(100, 80));
|
||
s.undo();
|
||
assert_eq!(damage(&s), None, "a size change recomposites the whole canvas");
|
||
// A structural change (a layer removed and restored) neither.
|
||
let b = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
select_all(&mut s, &[b]);
|
||
s.execute("layer.delete", json!({})).unwrap();
|
||
s.undo();
|
||
assert_eq!(damage(&s), None);
|
||
}
|
||
|
||
#[test]
|
||
fn undo_of_compositor_read_document_fields_composites_everything() {
|
||
let mut s = session(8);
|
||
let _ = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||
// Global Light changes every effect's angle across the document.
|
||
s.execute("layer.layerStyle.globalLight", json!({"angle": 90.0})).unwrap();
|
||
s.undo();
|
||
assert_eq!(damage(&s), None, "a global-light change recomposites everything");
|
||
// A mode change that keeps the pixel format (RGB → Indexed maps to RGB) neither.
|
||
s.execute("image.mode.indexedColor", json!({})).unwrap();
|
||
s.undo();
|
||
assert_eq!(damage(&s), None, "a mode change recomposites everything");
|
||
}
|
||
|
||
#[test]
|
||
fn undo_of_clipping_or_channel_exclusion_composites_everything() {
|
||
let mut s = session(8);
|
||
let _ = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||
let b = rect_layer(&mut s, Rect::new(12, 12, 30, 30));
|
||
select_all(&mut s, &[b]);
|
||
// Clipping reaches the layers clipped above, outside this layer's bounds.
|
||
s.execute("layer.setProps", json!({"clipped": true})).unwrap();
|
||
s.undo();
|
||
assert_eq!(damage(&s), None, "a clipping change recomposites everything");
|
||
s.execute("layer.setProps", json!({"channels": [true, false, true]})).unwrap();
|
||
s.undo();
|
||
assert_eq!(damage(&s), None, "an excluded-channel change recomposites everything");
|
||
}
|
||
|
||
#[test]
|
||
fn linked_layers_move_together_and_undo() {
|
||
for depth in [8, 16, 32] {
|
||
let mut s = session(depth);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 10, 10));
|
||
let b = rect_layer(&mut s, Rect::new(20, 20, 30, 30));
|
||
let c = rect_layer(&mut s, Rect::new(50, 50, 60, 60));
|
||
assert!(!s.is_enabled("layer.linkLayers"));
|
||
select_all(&mut s, &[a, b]);
|
||
assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true);
|
||
let g = doc(&s).layer(a).unwrap().link_group;
|
||
assert!(g.is_some() && doc(&s).layer(b).unwrap().link_group == g);
|
||
// Move only a: b follows, c stays.
|
||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||
s.execute("layer.translate", json!({"dx": 5, "dy": 3})).unwrap();
|
||
assert_eq!(bounds(&s, a), Rect::new(5, 3, 15, 13));
|
||
assert_eq!(bounds(&s, b), Rect::new(25, 23, 35, 33));
|
||
assert_eq!(bounds(&s, c), Rect::new(50, 50, 60, 60));
|
||
s.undo();
|
||
assert_eq!(bounds(&s, a), Rect::new(0, 0, 10, 10));
|
||
assert_eq!(bounds(&s, b), Rect::new(20, 20, 30, 30));
|
||
// Select Linked Layers from just a.
|
||
s.execute("layer.selectLinkedLayers", json!({})).unwrap();
|
||
assert_eq!(sel(&s), vec![a, b]);
|
||
// Multi-selection translate moves all selected (c added, unlinked).
|
||
s.execute("layer.select", json!({"layer": c.0, "mode": "toggle"})).unwrap();
|
||
s.execute("layer.translate", json!({"dx": -1, "dy": 0})).unwrap();
|
||
assert_eq!(bounds(&s, c), Rect::new(49, 50, 59, 60));
|
||
assert_eq!(bounds(&s, a), Rect::new(-1, 0, 9, 10));
|
||
// Toggle: linking the same group again unlinks.
|
||
select_all(&mut s, &[a, b]);
|
||
assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], false);
|
||
assert!(doc(&s).layer(a).unwrap().link_group.is_none());
|
||
}
|
||
}
|
||
|
||
/// Plain layers carrying stored link groups, as a loaded `.pcraft` or PSD can hold (#961).
|
||
fn stored_groups(s: &mut Session, groups: &[Option<u64>]) -> Vec<LayerId> {
|
||
let ids: Vec<LayerId> = groups.iter().map(|_| rect_layer(s, Rect::new(0, 0, 4, 4))).collect();
|
||
s.edit("load groups", |doc, _| {
|
||
for (id, g) in ids.iter().zip(groups) {
|
||
doc.layer_mut(*id).unwrap().link_group = *g;
|
||
}
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
ids
|
||
}
|
||
|
||
fn group(s: &Session, id: LayerId) -> Option<u64> {
|
||
doc(s).layer(id).unwrap().link_group
|
||
}
|
||
|
||
/// Links `ids`, then checks they share one group no other layer uses (#961).
|
||
fn link_fresh(s: &mut Session, ids: &[LayerId]) -> u64 {
|
||
select_all(s, ids);
|
||
let past = s.active().unwrap().history.past_len();
|
||
assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true);
|
||
assert_eq!(s.active().unwrap().history.past_len(), past + 1);
|
||
let g = group(s, ids[0]).expect("linked");
|
||
assert!(ids.iter().all(|id| group(s, *id) == Some(g)));
|
||
let members = doc(s).walk().iter().filter(|(_, _, l)| l.link_group == Some(g)).count();
|
||
assert_eq!(members, ids.len(), "group {g} is shared with unselected layers");
|
||
g
|
||
}
|
||
|
||
#[test]
|
||
fn link_layers_beside_a_maximal_stored_group() {
|
||
// The issue's repro: one layer stored at u64::MAX, link it with an unlinked one.
|
||
let mut s = session(8);
|
||
let ids = stored_groups(&mut s, &[Some(u64::MAX), None]);
|
||
assert_ne!(link_fresh(&mut s, &ids), u64::MAX);
|
||
// Unlink and relink the pair: with no groups left, numbering starts over at 1.
|
||
assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], false);
|
||
assert!(ids.iter().all(|id| group(&s, *id).is_none()));
|
||
assert_eq!(link_fresh(&mut s, &ids), 1);
|
||
|
||
// Two other layers linked beside an untouched maximal pair.
|
||
let mut s = session(8);
|
||
let ids = stored_groups(&mut s, &[Some(u64::MAX), Some(u64::MAX), None, None]);
|
||
let g = link_fresh(&mut s, &ids[2..]);
|
||
assert_ne!(g, u64::MAX);
|
||
assert_eq!((group(&s, ids[0]), group(&s, ids[1])), (Some(u64::MAX), Some(u64::MAX)));
|
||
}
|
||
|
||
#[test]
|
||
fn unlink_a_maximal_stored_group() {
|
||
let mut s = session(8);
|
||
let ids = stored_groups(&mut s, &[Some(u64::MAX), Some(u64::MAX), None]);
|
||
select_all(&mut s, &ids[..2]);
|
||
assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], false);
|
||
assert!(ids.iter().all(|id| group(&s, *id).is_none()));
|
||
s.undo();
|
||
assert_eq!((group(&s, ids[0]), group(&s, ids[1])), (Some(u64::MAX), Some(u64::MAX)));
|
||
// Relinking all three gets a working group.
|
||
link_fresh(&mut s, &ids);
|
||
}
|
||
|
||
#[test]
|
||
fn link_layers_when_every_group_is_huge() {
|
||
// Unselected groups at the top of the range, including the 0 a wrapped id would join.
|
||
let top = [u64::MAX, u64::MAX - 1, u64::MAX - 2, 0, 1, 2];
|
||
let stored: Vec<Option<u64>> = top.iter().flat_map(|g| [Some(*g), Some(*g)]).chain([None, None, None, None]).collect();
|
||
let mut s = session(8);
|
||
let ids = stored_groups(&mut s, &stored);
|
||
let n = top.len() * 2;
|
||
let g = link_fresh(&mut s, &ids[n..n + 2]);
|
||
assert!(!top.contains(&g), "{g} collides with a stored group");
|
||
// A second link gets yet another group, and existing groups never change.
|
||
let h = link_fresh(&mut s, &ids[n + 2..]);
|
||
assert!(!top.contains(&h) && h != g);
|
||
for (i, t) in top.iter().enumerate() {
|
||
assert_eq!((group(&s, ids[2 * i]), group(&s, ids[2 * i + 1])), (Some(*t), Some(*t)));
|
||
}
|
||
// Moving a newly linked layer leaves the group-0 pair alone.
|
||
assert_eq!(with_links(doc(&s), &[ids[n]]), vec![ids[n], ids[n + 1]]);
|
||
}
|
||
|
||
#[test]
|
||
fn link_layers_near_the_maximum() {
|
||
// max + 1 still fits: the new group is u64::MAX itself.
|
||
let mut s = session(8);
|
||
let ids = stored_groups(&mut s, &[Some(u64::MAX - 1), Some(u64::MAX - 1), None, None, None, None]);
|
||
assert_eq!(link_fresh(&mut s, &ids[2..4]), u64::MAX);
|
||
// The next link no longer fits above the maximum and takes a free id instead.
|
||
let g = link_fresh(&mut s, &ids[4..]);
|
||
assert!(g != u64::MAX && g != u64::MAX - 1);
|
||
}
|
||
|
||
#[test]
|
||
fn link_layers_assigns_working_groups() {
|
||
// Control: ordinary documents keep getting max + 1, starting at 1.
|
||
let mut s = session(8);
|
||
let ids = stored_groups(&mut s, &[None, None, None, None]);
|
||
assert_eq!(link_fresh(&mut s, &ids[..2]), 1);
|
||
assert_eq!(link_fresh(&mut s, &ids[2..]), 2);
|
||
assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], false);
|
||
assert_eq!((group(&s, ids[2]), group(&s, ids[3])), (None, None));
|
||
assert_eq!((group(&s, ids[0]), group(&s, ids[1])), (Some(1), Some(1)));
|
||
}
|
||
|
||
#[test]
|
||
fn selection_pruned_after_delete_and_undo() {
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
let b = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
let c = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
select_all(&mut s, &[a, c]);
|
||
// Deleting one layer explicitly prunes it from the set.
|
||
s.execute("layer.delete", json!({"layer": a.0})).unwrap();
|
||
assert_eq!(sel(&s), vec![c]);
|
||
s.undo();
|
||
assert!(sel(&s).iter().all(|id| doc(&s).layer(*id).is_some()));
|
||
// Deleting with several selected removes them all, then selects the layer below.
|
||
select_all(&mut s, &[a, c]);
|
||
s.execute("layer.delete", json!({})).unwrap();
|
||
assert!(doc(&s).layer(a).is_none() && doc(&s).layer(c).is_none());
|
||
assert_eq!(doc(&s).layer_count(), 2);
|
||
assert_eq!(sel(&s).len(), 1);
|
||
assert!(s.active().unwrap().selected_layers.iter().all(|id| doc(&s).layer(*id).is_some()));
|
||
s.undo();
|
||
assert_eq!(doc(&s).layer_count(), 4);
|
||
let _ = b;
|
||
// Redo then undo again keeps the set valid.
|
||
s.redo();
|
||
assert!(sel(&s).iter().all(|id| doc(&s).layer(*id).is_some()));
|
||
}
|
||
|
||
#[test]
|
||
fn duplicate_hide_lock_and_reverse_selected() {
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
let b = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
let c = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
select_all(&mut s, &[a, c]);
|
||
let r = s.execute("layer.duplicate", json!({})).unwrap();
|
||
assert_eq!(r["layers"].as_array().unwrap().len(), 2);
|
||
assert_eq!(doc(&s).layer_count(), 6);
|
||
assert_eq!(sel(&s).len(), 2);
|
||
assert!(sel(&s).iter().all(|id| *id != a && *id != c));
|
||
s.undo();
|
||
select_all(&mut s, &[a, b]);
|
||
s.execute("layer.hideLayers", json!({})).unwrap();
|
||
assert!(!doc(&s).layer(a).unwrap().visible && !doc(&s).layer(b).unwrap().visible && doc(&s).layer(c).unwrap().visible);
|
||
s.execute("layer.showLayers", json!({})).unwrap();
|
||
assert!(doc(&s).layer(a).unwrap().visible);
|
||
s.execute("layer.lockLayers", json!({})).unwrap();
|
||
assert!(doc(&s).layer(a).unwrap().locks.all && doc(&s).layer(b).unwrap().locks.all);
|
||
assert!(s.execute("layer.translate", json!({"dx": 1})).is_err(), "locked layers don't move");
|
||
s.execute("layer.lockLayers", json!({})).unwrap();
|
||
assert!(!doc(&s).layer(a).unwrap().locks.all);
|
||
s.execute("layer.lockLayers", json!({"position": true})).unwrap();
|
||
assert!(doc(&s).layer(b).unwrap().locks.position && !doc(&s).layer(c).unwrap().locks.position);
|
||
// Reverse a, b, c.
|
||
select_all(&mut s, &[a, b, c]);
|
||
s.execute("layer.arrange.reverse", json!({})).unwrap();
|
||
assert_eq!(doc(&s).layers.iter().skip(1).map(|l| l.id).collect::<Vec<_>>(), vec![c, b, a]);
|
||
assert_eq!(s.active().unwrap().history.entries().last().map(|e| e.as_str()), Some("Reverse"));
|
||
}
|
||
|
||
#[test]
|
||
fn inspect_reports_selection() {
|
||
let mut s = session(8);
|
||
let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
let b = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||
select_all(&mut s, &[a, b]);
|
||
let v = s.execute("document.inspect", json!({})).unwrap();
|
||
assert_eq!(v["selectedLayers"], json!([a.0, b.0]));
|
||
assert_eq!(v["layers"][0]["selected"], true);
|
||
}
|
||
}
|