Compare commits

...
Author SHA1 Message Date
Tanish Manku e27fe467f9 Paste Layer Style keeps Blend If normalised across modes; Align docs say it follows an active selection for any count; undo, group and 16-bit tests 2026-10-15 12:37:16 -07:00
Tanish Manku 1b5294c877 Paste Layer Style carries Blend If and channels; align to an active selection with several layers
From the layers audit (L2):
- Copy / Paste Layer Style copied the effects, blend mode, Fill and advanced blending but
  dropped Blend If and the channel restrictions; Photoshop's layer style carries all of its
  Blending Options. Across colour modes only Blend If's Gray range carries over and the
  channels reset (RGB channel ranges mean nothing in CMYK). The copied style is now a named
  struct (CopiedStyle) instead of a tuple.
- With an active selection, aligning (Layer › Align, "auto") aligned two or more layers to
  their own bounds; Photoshop aligns them to the selection (Layer › Align Layers to
  Selection), as it already did for one layer.
2026-10-14 16:16:00 -07:00
4 changed files with 151 additions and 11 deletions

No files matched your search

+30 -6
View File
@@ -878,8 +878,17 @@ pub fn specs() -> Vec<CommandSpec> {
),
spec!("layer.layerStyle.copyLayerStyle", "Copy Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?}"##, has_layer, |s, p| {
let id = layer_param(s, p)?;
let l = s.active().and_then(|d| d.doc.layer(id)).ok_or(EngineError::NoLayer(id))?;
let style = (l.effects.clone(), l.blend, l.fill_opacity, l.advanced);
let d = s.active().ok_or(EngineError::NoDocument)?;
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let style = crate::CopiedStyle {
effects: l.effects.clone(),
blend: l.blend,
fill: l.fill_opacity,
advanced: l.advanced,
blend_if: l.blend_if.clone(),
excluded_channels: l.excluded_channels,
mode: d.doc.mode,
};
s.style_clipboard = Some(style);
Ok(Value::Null)
}),
@@ -895,17 +904,32 @@ pub fn specs() -> Vec<CommandSpec> {
},
|s, p| {
let ids = style_targets(s, p, |_| true)?;
let (mut fx, blend, fill, advanced) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?;
let style = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?;
let mut fx = style.effects;
s.edit("Paste Layer Style", |doc, _| {
// A style copied from a document in another mode takes this one's colours.
let mode = doc.mode;
fx.items = std::mem::take(&mut fx.items).into_iter().map(|e| e.in_mode(mode)).collect();
// Blend If's Gray range carries over to any mode; its channel ranges and the
// channel restrictions only to the mode they were set in.
let same_mode = style.mode == mode;
let blend_if = if same_mode {
style.blend_if.clone()
} else {
// Through `set`, which keeps the normalised form (no trailing full
// entries), so a full Gray range pastes as the default.
let mut gray = photocraft_doc::BlendIf::default();
gray.set(0, style.blend_if.get(0));
gray
};
for id in ids {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.effects = fx.clone();
l.blend = blend;
l.fill_opacity = fill;
l.advanced = advanced;
l.blend = style.blend;
l.fill_opacity = style.fill;
l.advanced = style.advanced;
l.blend_if = blend_if.clone();
l.excluded_channels = if same_mode { style.excluded_channels } else { 0 };
}
Ok(())
})?;
+6 -4
View File
@@ -263,7 +263,8 @@ 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.
// Photoshop aligns to an active selection however many layers are selected; without one,
// a single layer aligns to the canvas and 2+ align to each other.
_ => Ok(()),
}
}
@@ -961,8 +962,9 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
"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(),
// Photoshop: with an active selection, the layers align to its bounds (Layer › Align
// Layers to Selection), however many are selected.
(Some(m), _) if to == "auto" && !m.content_bounds().is_empty() => 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)),
@@ -1431,7 +1433,7 @@ pub fn specs() -> Vec<CommandSpec> {
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)"##;
const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the active selection's bounds when there is one, however many layers are selected; else one layer aligns to the canvas and several to their combined 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_doc, |s, _| {
+16 -1
View File
@@ -391,6 +391,21 @@ impl Default for ToolState {
}
}
/// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options, as Photoshop
/// copies them (Paste Layer Style puts them all on each target).
#[derive(Clone, Debug)]
pub struct CopiedStyle {
pub effects: photocraft_doc::Effects,
pub blend: photocraft_color::BlendMode,
pub fill: f32,
pub advanced: photocraft_doc::AdvancedBlending,
pub blend_if: photocraft_doc::BlendIf,
pub excluded_channels: u32,
/// The colour mode it was copied in: Blend If's channel ranges and the channel restrictions
/// only carry over to a document in the same mode.
pub mode: photocraft_color::ColorMode,
}
#[derive(Default)]
pub struct Session {
docs: Vec<DocState>,
@@ -404,7 +419,7 @@ pub struct Session {
pub clipboard: Option<edit_cmds::Clip>,
/// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced
/// Blending switches.
pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32, photocraft_doc::AdvancedBlending)>,
pub style_clipboard: Option<CopiedStyle>,
/// Shape path context menu: copied vector fill and stroke styles.
pub path_fill_clipboard: Option<photocraft_doc::Fill>,
pub path_stroke_clipboard: Option<photocraft_doc::ShapeStroke>,
+99
View File
@@ -0,0 +1,99 @@
//! Copy / Paste Layer Style carries the whole style, Blending Options included (Blend If and the
//! channel restrictions); align with an active selection aligns to it however many layers are
//! selected (layers audit L2).
use photocraft_engine::Session;
use serde_json::json;
fn layer(s: &Session, id: u64) -> photocraft_doc::Layer {
s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone()
}
fn new_doc(s: &mut Session, mode: &str) -> (u64, u64) {
s.execute("file.new", json!({"width": 64, "height": 64, "mode": mode, "background": "white"})).unwrap();
let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
(a, b)
}
#[test]
fn paste_layer_style_carries_blend_if_and_channels() {
let mut s = Session::new();
let (a, b) = new_doc(&mut s, "rgb");
s.execute(
"layer.layerStyle.blendingOptions",
json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}, {"channel": "red", "thisLayer": [10, 200]}]}),
)
.unwrap();
s.execute("layer.setProps", json!({"layer": a, "channels": [true, false, true]})).unwrap();
s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap();
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": b})).unwrap();
let (la, lb) = (layer(&s, a), layer(&s, b));
assert_eq!(lb.blend_if, la.blend_if, "Blend If, Gray and channel ranges");
assert_eq!(lb.excluded_channels, la.excluded_channels, "the unchecked G channel");
// Into a CMYK document: only the Gray range carries over; channels reset to all.
let (c, _) = new_doc(&mut s, "cmyk");
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap();
let lc = layer(&s, c);
assert_eq!(lc.blend_if.get(0), la.blend_if.get(0));
assert_eq!(lc.blend_if.ranges.len(), 1, "no RGB channel range on CMYK");
assert_eq!(lc.excluded_channels, 0);
}
#[test]
fn paste_layer_style_into_another_mode_keeps_blend_if_normalised() {
// A full Gray range and only a Red range: in CMYK nothing is left, so it is the default
// (which compares equal, so the paste isn't seen as a Blend If change).
let mut s = Session::new();
let (a, _) = new_doc(&mut s, "rgb");
s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "red", "thisLayer": [10, 200]}]})).unwrap();
s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap();
let (c, _) = new_doc(&mut s, "cmyk");
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap();
assert_eq!(layer(&s, c).blend_if, photocraft_doc::BlendIf::default());
}
#[test]
fn paste_layer_style_on_several_layers_is_one_undo_step() {
// 16-bit, onto a pixel layer and a group: one History step, and one Undo restores both.
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 64, "depth": 16, "background": "white"})).unwrap();
let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}]})).unwrap();
s.execute("layer.setProps", json!({"layer": a, "fill": 0.5, "blend": "multiply"})).unwrap();
s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap();
let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap();
let before = (layer(&s, b), layer(&s, g));
s.execute("layer.select", json!({"layer": b})).unwrap();
s.execute("layer.select", json!({"layer": g, "mode": "add"})).unwrap();
s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap();
let la = layer(&s, a);
for id in [b, g] {
let l = layer(&s, id);
assert_eq!((l.blend_if.clone(), l.fill_opacity, l.blend), (la.blend_if.clone(), la.fill_opacity, la.blend), "layer {id}");
}
s.execute("edit.undo", json!({})).unwrap();
let after = (layer(&s, b), layer(&s, g));
assert_eq!((after.0.blend_if, after.0.blend, after.1.blend_if, after.1.blend), (before.0.blend_if, before.0.blend, before.1.blend_if, before.1.blend));
assert_eq!((after.0.fill_opacity, after.1.fill_opacity), (before.0.fill_opacity, before.1.fill_opacity));
}
#[test]
fn several_layers_align_to_an_active_selection() {
let mut s = Session::new();
let (a, b) = new_doc(&mut s, "rgb");
for (id, x) in [(a, 4), (b, 30)] {
s.execute("layer.select", json!({"layer": id})).unwrap();
s.execute("select.rect", json!({"x": x, "y": 10, "width": 8, "height": 8})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
}
s.execute("select.rect", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap();
s.execute("layer.select", json!({"layer": a})).unwrap();
s.execute("layer.select", json!({"layer": b, "mode": "add"})).unwrap();
s.execute("layer.align.leftEdges", json!({})).unwrap();
for id in [a, b] {
let x0 = layer(&s, id).surface().unwrap().content_bounds().x0;
assert_eq!(x0, 40, "layer {id} aligned to the selection's left edge");
}
}