Merge 1734-fxdrag
This commit is contained in:
commit
67cf1ea6e4
|
|
@ -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<Value> {
|
|||
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<Value> {
|
||||
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<CommandSpec> {
|
|||
),
|
||||
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…",
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>();
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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::<u64>(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::<FxDrag>(fx_drag_key()));
|
||||
}
|
||||
let held = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("layer-drag")).is_some() || d.get_temp::<FxDrag>(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::<u64>(egui::Id::new("layer-drag")));
|
||||
ctx.data_mut(|d| d.remove::<FxDrag>(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<usize>);
|
||||
|
||||
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<usize>) {
|
||||
ctx.data_mut(|d| d.insert_temp::<FxDrag>(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::<FxDrag>(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<usize>, 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::<FxDrag>(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::*;
|
||||
|
|
|
|||
Loading…
Reference in New Issue