mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-15 11:46:52 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b5294c877 |
No files matched your search
@@ -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,27 @@ 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 mut blend_if = style.blend_if.clone();
|
||||
if !same_mode {
|
||||
blend_if.ranges.truncate(1);
|
||||
}
|
||||
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(())
|
||||
})?;
|
||||
|
||||
@@ -961,8 +961,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)),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! 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 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");
|
||||
}
|
||||
}
|
||||
@@ -2624,8 +2624,6 @@ mod transform_fx_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod transform_undo_tests;
|
||||
#[cfg(test)]
|
||||
mod transform_switch_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod transform_type_tests;
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
//! Free Transform with the whole app: picking another tool or layer, or New Layer, while the box
|
||||
//! is open applies the transform as it stands, as Photoshop does (#5441, #5665, #5667).
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// The app with a 400 × 300 document: a red 100 × 60 rectangle at (100, 100) on its own layer
|
||||
/// above the Background.
|
||||
fn app() -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
h
|
||||
}
|
||||
|
||||
/// Open Free Transform and move the box 50 px right, 40 px down.
|
||||
fn transform_and_move(h: &mut Harness<'static, PhotocraftApp>) {
|
||||
let ctx = h.ctx.clone();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open");
|
||||
for p in &mut t.quad {
|
||||
p[0] += 50.0;
|
||||
p[1] += 40.0;
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn red_bounds(h: &Harness<'static, PhotocraftApp>) -> photocraft_geom::Rect {
|
||||
let st = h.state().session.active().unwrap();
|
||||
let top = st.doc.layers.last().unwrap();
|
||||
top.surface().unwrap().content_bounds()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_another_tool_in_the_toolbar_applies_the_transform() {
|
||||
let mut h = app();
|
||||
transform_and_move(&mut h);
|
||||
let hand = h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool");
|
||||
hand.click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.transform.is_none(), "the box closed");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "the move was applied, not cancelled");
|
||||
}
|
||||
|
||||
/// The box on a just-placed layer (a file dropped on the canvas), moved.
|
||||
fn placed_and_moved(h: &mut Harness<'static, PhotocraftApp>) {
|
||||
let ctx = h.ctx.clone();
|
||||
crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap();
|
||||
h.run_steps(2);
|
||||
let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open");
|
||||
for p in &mut t.quad {
|
||||
p[0] += 50.0;
|
||||
p[1] += 40.0;
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_image_is_applied_when_another_tool_is_picked() {
|
||||
let mut h = app();
|
||||
placed_and_moved(&mut h);
|
||||
h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.transform.is_none());
|
||||
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_another_layer_in_the_panel_applies_the_transform() {
|
||||
for placed in [false, true] {
|
||||
let mut h = app();
|
||||
if placed {
|
||||
placed_and_moved(&mut h);
|
||||
} else {
|
||||
transform_and_move(&mut h);
|
||||
}
|
||||
h.get_by_label("Background").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.transform.is_none(), "placed {placed}");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "placed {placed}");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layer_applies_the_transform_first() {
|
||||
for placed in [false, true] {
|
||||
let mut h = app();
|
||||
if placed {
|
||||
placed_and_moved(&mut h);
|
||||
} else {
|
||||
transform_and_move(&mut h);
|
||||
}
|
||||
// The Layers panel's New Layer button (the menu item opens the New Layer dialog).
|
||||
h.state_mut().run("layer.new.layer", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.transform.is_none(), "placed {placed}");
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 3, "placed {placed}");
|
||||
let red = st.doc.layers[1].surface().unwrap().content_bounds();
|
||||
assert_eq!(red, photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}: the moved image, not the original");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_placed_image_stays_when_another_tool_or_layer_is_picked() {
|
||||
for via_layer in [false, true] {
|
||||
let mut h = app();
|
||||
let ctx = h.ctx.clone();
|
||||
crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap();
|
||||
h.run_steps(2);
|
||||
if via_layer {
|
||||
let bg = h.state().session.active().unwrap().doc.layers[0].id;
|
||||
h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap();
|
||||
} else {
|
||||
h.query_by_label_contains("Hand Tool").expect("Hand Tool").click();
|
||||
}
|
||||
h.run_steps(4);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 2, "via layer {via_layer}: the placed image stays");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user