diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index 1eda5dd8..55595ef8 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -5,7 +5,7 @@ use photocraft_algo::selection::Region; use photocraft_color::{BlendMode, ColorMode}; -use photocraft_doc::{BlendIf, BlendRange, Document, Effect, Knockout, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode}; +use photocraft_doc::{BlendIf, BlendRange, Document, Effect, Effects, Knockout, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; @@ -605,6 +605,68 @@ fn scale_effects(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Dragging a layer's fx onto another layer in the Layers panel: all its effects (or just the one +/// at `effect`) move there, or with `copy` are copied. Moving all effects replaces the target's +/// style, as pasting a layer style does; a single effect replaces the target's effect of the same +/// kind, else is added. +fn transfer_effects(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.layerStyle.transferEffects"; + let id_of = |key: &str| p.get(key).and_then(Value::as_u64).map(LayerId).ok_or_else(|| bad(CMD, format!("`{key}` must be a layer id"))); + let from = id_of("from")?; + let to = id_of("to")?; + if from == to { + return Err(bad(CMD, "the effects are already on that layer")); + } + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + let index = match p.get("effect") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(CMD, "`effect` must be an effect index"))?), + }; + let d = s.active().ok_or(EngineError::NoDocument)?; + let src = d.doc.layer(from).ok_or(EngineError::NoLayer(from))?; + d.doc.layer(to).ok_or(EngineError::NoLayer(to))?; + let payload = match index { + Some(i) => { + let e = src.effects.items.get(i).cloned().ok_or_else(|| bad(CMD, format!("the layer has no effect {i}")))?; + Effects { enabled: true, items: vec![e], psd_raw: None, reference: None } + } + None => Effects { psd_raw: None, ..src.effects.clone() }, + }; + if payload.items.is_empty() { + return Err(bad(CMD, "the layer has no layer effects")); + } + let label = if copy { "Copy Layer Effects" } else { "Move Layer Effects" }; + s.edit(label, |doc, _| { + let dst = doc.layer_mut(to).ok_or(EngineError::NoLayer(to))?; + dst.effects.psd_raw = None; + if index.is_some() { + dst.effects.enabled = true; + for e in payload.items { + let same = dst.effects.items.iter().position(|x| std::mem::discriminant(x) == std::mem::discriminant(&e)); + match same.and_then(|i| dst.effects.items.get_mut(i)) { + Some(slot) => *slot = e, + None => dst.effects.items.push(e), + } + } + } else { + dst.effects = payload; + } + if !copy { + let src = doc.layer_mut(from).ok_or(EngineError::NoLayer(from))?; + match index { + Some(i) if i < src.effects.items.len() => { + src.effects.items.remove(i); + src.effects.psd_raw = None; + } + Some(_) => {} + None => src.effects = Effects::default(), + } + } + Ok(()) + })?; + Ok(json!({"from": from.0, "to": to.0})) +} + /// Effects drawn beneath the layer (they become separate layers below it, unclipped). fn is_below(e: &Effect) -> bool { match e { @@ -861,6 +923,14 @@ pub fn specs() -> Vec { ), spec!("layer.layerStyle.createLayer", "Create Layer", ["Layer", "Layer Style"], r##"{"layer":id?}"##, has_effects, create_layer), spec!("layer.layerStyle.scaleEffects", "Scale Effects…", ["Layer", "Layer Style"], r##"{"scale":1..1000=100}"##, has_effects, scale_effects), + spec!( + "layer.layerStyle.transferEffects", + "Move Layer Effects", + [], + r##"{"from":id,"to":id,"effect":index? (default: all effects),"copy":bool=false}"##, + has_doc, + transfer_effects + ), CommandSpec { id: "layer.layerContentOptions", label: "Layer Content Options…", diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index f8e88dcc..1eae5b4e 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -526,3 +526,56 @@ fn load_files_into_stack_as_smart_object_then_median() { assert!((px[0] - 0.2).abs() < 2.0 / 255.0, "{px:?}"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn transfer_effects_moves_or_copies_between_layers() { + let mut s = session(8, "rgb"); + s.execute("layer.new.layer", json!({})).unwrap(); + let a = active(&s).id; + s.execute("layer.layerStyle.dropShadow", json!({})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff8000"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let b = active(&s).id; + let layer = |s: &Session, id| s.active().unwrap().doc.layer(id).unwrap().clone(); + let labels = |l: &Layer| l.effects.items.iter().map(Effect::label).collect::>(); + let both = labels(&layer(&s, a)); + assert_eq!(both.len(), 2); + + // Alt-drag of the whole fx row copies: both layers have the style. + s.execute("layer.layerStyle.transferEffects", json!({"from": a.0, "to": b.0, "copy": true})).unwrap(); + assert_eq!(labels(&layer(&s, a)), both); + assert_eq!(labels(&layer(&s, b)), both); + + // Dragging one effect row moves just that effect. + s.execute("layer.new.layer", json!({})).unwrap(); + let c = active(&s).id; + s.execute("layer.layerStyle.transferEffects", json!({"from": a.0, "to": c.0, "effect": 1})).unwrap(); + assert_eq!(labels(&layer(&s, a)), both[..1]); + assert_eq!(labels(&layer(&s, c)), both[1..]); + + // Dragging the whole fx row moves everything and leaves the source without a style. + s.execute("layer.layerStyle.transferEffects", json!({"from": b.0, "to": a.0})).unwrap(); + assert!(layer(&s, b).effects.items.is_empty()); + assert_eq!(labels(&layer(&s, a)), both); + + // One history step per drop. + let steps = s.active().unwrap().history.past_len(); + s.execute("layer.layerStyle.transferEffects", json!({"from": a.0, "to": b.0})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(labels(&layer(&s, a)), both); + assert!(layer(&s, b).effects.items.is_empty()); + + // Bad input is an error and changes nothing. + for bad in [ + json!({"from": a.0, "to": a.0}), + json!({"from": a.0}), + json!({"from": a.0, "to": 9999}), + json!({"from": a.0, "to": b.0, "effect": 7}), + json!({"from": a.0, "to": b.0, "effect": -1}), + json!({"from": b.0, "to": a.0}), + ] { + assert!(s.execute("layer.layerStyle.transferEffects", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(s.active().unwrap().history.past_len(), steps); +} diff --git a/crates/ui-egui/src/fx_drag_tests.rs b/crates/ui-egui/src/fx_drag_tests.rs new file mode 100644 index 00000000..a2f2f7ca --- /dev/null +++ b/crates/ui-egui/src/fx_drag_tests.rs @@ -0,0 +1,86 @@ +//! #1719: layer effects are dragged onto another layer in the Layers panel, as in Photoshop: an +//! effect row or the fx badge moves its effects there, ⌥-drag copies them. + +use egui::{Modifiers, PointerButton, Pos2, Rect, accesskit::Role, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use photocraft_doc::LayerId; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::layer_row_ui::{Indicator, RowRects, recorded}; + +/// `a` with a Drop Shadow and a Color Overlay, and `b` above it without effects. +fn harness() -> (Harness<'static, PhotocraftApp>, LayerId, LayerId) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let a = s.execute("layer.new.layer", json!({"name": "a"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff8000"})).unwrap(); + let b = s.execute("layer.new.layer", json!({"name": "b"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(8); + (h, LayerId(a), LayerId(b)) +} + +fn row(h: &Harness<'_, PhotocraftApp>, id: LayerId) -> RowRects { + recorded(&h.ctx).into_iter().find(|r| r.layer == id.0).unwrap() +} + +/// The effect row `name` under layer `id`'s row. +fn effect_row(h: &Harness<'_, PhotocraftApp>, id: LayerId, name: &str) -> Rect { + let layer = row(h, id).row; + h.query_all_by_role(Role::Button) + .filter(|n| n.accesskit_node().label().as_deref() == Some(name)) + .map(|n| n.rect()) + .find(|r| r.top() >= layer.bottom() - 1.0 && r.left() < layer.center().x && r.right() > layer.center().x) + .unwrap_or_else(|| panic!("no {name} row under the layer")) +} + +fn effects(h: &Harness<'_, PhotocraftApp>, id: LayerId) -> Vec<&'static str> { + h.state().session.active().unwrap().doc.layer(id).unwrap().effects.items.iter().map(photocraft_doc::Effect::label).collect() +} + +/// A real press, drag and release, `modifiers` held throughout. +fn drag(h: &mut Harness<'_, PhotocraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.event(egui::Event::ModifiersChanged(modifiers)); + h.hover_at(from); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }); + h.run_steps(1); + for i in 1..=6 { + h.hover_at(from + (to - from) * (i as f32 / 6.0)); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(1); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +#[test] +fn dragging_effects_onto_a_layer_moves_them_and_alt_copies() { + let (mut h, a, b) = harness(); + assert_eq!(effects(&h, a), ["Drop Shadow", "Color Overlay"]); + let steps = h.state().session.active().unwrap().history.past_len(); + + // ⌥-drag one effect row onto `b`: just that effect is copied. + let from = effect_row(&h, a, "Color Overlay").center(); + let to = row(&h, b).row.center(); + drag(&mut h, from, to, Modifiers::ALT); + assert_eq!(effects(&h, a), ["Drop Shadow", "Color Overlay"]); + assert_eq!(effects(&h, b), ["Color Overlay"]); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "one history step"); + + // Drag the fx badge onto `b`: all of `a`'s effects move there, replacing `b`'s. + let badge = row(&h, a).indicators.into_iter().find(|(k, _)| *k == Indicator::Fx).unwrap().1; + let to = row(&h, b).row.center(); + drag(&mut h, badge.center(), to, Modifiers::NONE); + assert!(effects(&h, a).is_empty(), "the effects moved off `a`"); + assert_eq!(effects(&h, b), ["Drop Shadow", "Color Overlay"]); + // The drag moved effects, not the layer: `b` is still above `a`. + assert!(row(&h, b).row.top() < row(&h, a).row.top()); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 29499f92..1e89eab8 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1587,9 +1587,14 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .min_scrolled_height(if fill { rows_h } else { 0.0 }) .auto_shrink([false, !fill]) .show(ui, |ui| { - // The drag key is set only by actual layer-row drags, not clicks or + // The drag keys are set only by actual layer-row and fx drags, not clicks or // ordinary scrolling. The ScrollArea applies this to its own content. - let dragging = ctx.data(|d| d.get_temp::(egui::Id::new("layer-drag"))).is_some() && ctx.input(|i| i.pointer.primary_down()); + // An fx drag that never saw its release (the panel was hidden) must not outlive the button. + if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) { + ctx.data_mut(|d| d.remove::(fx_drag_key())); + } + let held = ctx.data(|d| d.get_temp::(egui::Id::new("layer-drag")).is_some() || d.get_temp::(fx_drag_key()).is_some()); + let dragging = held && ctx.input(|i| i.pointer.primary_down()); let pointer = ctx.input(|i| i.pointer.interact_pos()); let delta = layer_drag_edge_scroll(pointer, ui.clip_rect(), dragging, ctx.input(|i| i.stable_dt)); if delta != 0.0 { @@ -1645,9 +1650,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let in_selection = selection.iter().any(|s| s.0 == id); (id, in_selection) }); + fx_drag_feedback(&ctx, &doc); // End any layer drag after every row has had a chance to accept the drop. if ctx.input(|i| i.pointer.any_released()) { ctx.data_mut(|d| d.remove::(egui::Id::new("layer-drag"))); + ctx.data_mut(|d| d.remove::(fx_drag_key())); } widgets::panel_footer(ui, |ui| { let trash = icons::button(ui, "trash", 26.0, false, tl!("Delete layer")); @@ -1887,6 +1894,7 @@ fn layer_row( let row_h = if t.pro { 32.0 } else { 46.0 }; let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag()); layer_drag_and_drop(app, ctx, ui, l, rect, &resp, actions); + fx_drop(ctx, ui, l, rect, actions); if resp.drag_started() { crate::layer_transfer::begin_from_panel(app, ctx, l.id); } @@ -1978,6 +1986,13 @@ fn layer_row( // Right-hand indicators first; the name gets what is left and ends in "…" (#144). let fx_open = app.session.active().is_none_or(|d| !d.fx_collapsed.contains(&l.id)); let (name_right, indicators, fx_toggled) = crate::layer_row_ui::indicators(ui, &painter, rect, x, l, fx_open, actions); + // Dragging the fx badge drags all the layer's effects, not the layer (Photoshop). Like the eye, + // it takes the drag from the row; clicks still reach the row. + if let Some(&(_, badge)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Fx) + && ui.interact(badge, ui.id().with(("fx-badge", l.id.0)), Sense::drag()).drag_started() + { + start_fx_drag(ctx, l.id, None); + } let name_color = if l.visible { t.text } else { t.text_faint }; let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) }; // Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright. @@ -2826,6 +2841,60 @@ fn layer_drag_and_drop( } } +/// An fx row being dragged: the layer it belongs to and the effect index (`None` = the "Effects" +/// row, all of them). +type FxDrag = (u64, Option); + +fn fx_drag_key() -> egui::Id { + egui::Id::new("fx-drag") +} + +/// Start dragging the effects of `layer`: one (`effect`) or all of them. +fn start_fx_drag(ctx: &egui::Context, layer: LayerId, effect: Option) { + ctx.data_mut(|d| d.insert_temp::(fx_drag_key(), (layer.0, effect))); +} + +/// While effects are dragged: their name by the pointer, and the copy cursor while ⌥ is held. +fn fx_drag_feedback(ctx: &egui::Context, doc: &photocraft_doc::Document) { + let Some((from, effect)) = ctx.data(|d| d.get_temp::(fx_drag_key())) else { return }; + let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + let label = match effect { + Some(i) => doc.layer(LayerId(from)).and_then(|l| l.effects.items.get(i)).map_or("", |e| e.label()), + None => "Effects", + }; + crate::layer_transfer::ghost(ctx, p, label); + if ctx.input(|i| i.modifiers.alt) { + ctx.set_cursor_icon(egui::CursorIcon::Copy); + } +} + +/// The command for dropping effects of layer `from` on `target`: they move there, or with ⌥ held +/// on release are copied (Photoshop). `effect` is one effect row, or all effects when `None`. +fn fx_drop_action(from: u64, effect: Option, target: LayerId, copy: bool) -> (String, Value) { + let mut payload = json!({"from": from, "to": target.0, "copy": copy}); + if let Some(o) = payload.as_object_mut() + && let Some(i) = effect + { + o.insert("effect".into(), json!(i)); + } + ("layer.layerStyle.transferEffects".into(), payload) +} + +/// A layer row accepts the fx row being dragged: outlined while the pointer is over it, dropped +/// on release. +fn fx_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: &mut Vec<(String, Value)>) { + let Some((from, effect)) = ctx.data(|d| d.get_temp::(fx_drag_key())) else { return }; + let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + if from == l.id.0 || !rect.contains(p) { + return; + } + let t = Tokens::get(ctx); + ui.painter().rect_stroke(rect.shrink(1.0), t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside); + if ctx.input(|i| i.pointer.any_released()) { + actions.push(fx_drop_action(from, effect, l.id, ctx.input(|i| i.modifiers.alt))); + } +} + /// Photoshop shows a layer's effects as indented sub-rows ("Effects", then each effect). The eye /// on "Effects" shows or hides them all, the eye on an effect's row just that one (#1622). fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { @@ -2837,7 +2906,11 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi rows.push((e.label().to_string(), e.enabled(), kind)); } for (i, (name, on, kind)) in rows.into_iter().enumerate() { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); + // Drag the row onto another layer to move its effects there, ⌥-drag to copy them. + if resp.drag_started() { + start_fx_drag(ui.ctx(), l.id, i.checked_sub(1)); + } if !ui.is_rect_visible(rect) { continue; } @@ -3173,6 +3246,10 @@ mod history_transform_tests { #[path = "layer_pct_slider_tests.rs"] mod layer_pct_slider_tests; +#[cfg(test)] +#[path = "fx_drag_tests.rs"] +mod fx_drag_tests; + #[cfg(test)] mod lock_tests { use super::*;