Layers panel: a dragged layer copies onto its own row and into the empty space below the stack (#1721) (#2525)

* Layers panel: a dragged layer copies onto its own row and into the empty space below the stack (#1721)

* rustfmt

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Gert Pellin - GPE
2026-10-10 14:23:22 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent eab15ffdf6
commit 9ccea32651
2 changed files with 150 additions and 3 deletions
+71
View File
@@ -359,6 +359,77 @@ fn alt_dragging_a_row_drops_a_copy_and_a_plain_drag_moves() {
assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Reorder Layers"));
}
/// #1721: a press-move-release from `from` to `to`, with `release` held only when the row is
/// dropped.
fn drag_from_to(h: &mut Harness<'static, PhotocraftApp>, from: Pos2, to: Pos2, release: Modifiers) {
h.event(egui::Event::PointerMoved(from));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for k in 1..=8 {
h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
h.run_steps(1);
}
h.event(egui::Event::ModifiersChanged(release));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: release });
h.run_steps(1);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
/// #1721: with a single layer there is no other row to drop on, so with ⌥ held the dragged row
/// accepts the drop onto itself and the copy lands above it. Without ⌥ the drop on itself does
/// nothing.
#[test]
fn alt_dragging_the_only_layer_drops_a_copy_on_itself() {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let mut h = harness(s, 1.0, "promedium", 290.0);
let names = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
let steps = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.past_len();
assert_eq!(names(&h), ["Background"]);
let row = recorded(&h.ctx)[0].row;
let from = pos2(row.center().x, row.bottom() - 4.0);
let before = steps(&h);
drag_from_to(&mut h, from, pos2(row.center().x, row.top() + row.height() * 0.25), Modifiers::ALT);
assert_eq!(names(&h), ["Background", "Background copy"]);
assert_eq!(steps(&h), before + 1, "one undo step");
assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Duplicate Layer"));
// A plain drop back on the same layer leaves everything as it is.
let row = recorded(&h.ctx)[0].row;
let before = steps(&h);
drag_from_to(&mut h, from, pos2(row.center().x, row.top() + row.height() * 0.75), Modifiers::NONE);
assert_eq!(names(&h), ["Background", "Background copy"], "a plain drop on itself does nothing");
assert_eq!(steps(&h), before);
}
/// #1721: the empty space below the bottom-most row is a drop target too (Photoshop): ⌥ copies
/// the layer to the very bottom of the stack, a plain drag moves it there.
#[test]
fn dropping_below_the_bottom_row_lands_at_the_bottom_of_the_stack() {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
for i in 0..2 {
s.execute("layer.new.layer", json!({"name": format!("L{i}")})).unwrap();
}
let mut h = harness(s, 1.0, "promedium", 290.0);
let names = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
// Rows top to bottom: L1, L0, Background. The empty space starts under Background.
let top = recorded(&h.ctx)[0].row.center();
let bottom = recorded(&h.ctx)[2].row;
drag_from_to(&mut h, top, pos2(bottom.center().x, bottom.bottom() + 10.0), Modifiers::ALT);
assert_eq!(names(&h), ["L1 copy", "Background", "L0", "L1"]);
assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Duplicate Layer"));
// Rows: L1, L0, Background, L1 copy. A plain drag of L0 to the empty space moves it to the
// very bottom.
let grabbed = recorded(&h.ctx)[1].row.center();
let bottom = recorded(&h.ctx)[3].row;
drag_from_to(&mut h, grabbed, pos2(bottom.center().x, bottom.bottom() + 10.0), Modifiers::NONE);
assert_eq!(names(&h), ["L0", "L1 copy", "Background", "L1"]);
assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Reorder Layers"));
}
/// Photoshop's Layers footer is a bar along the panel's bottom edge: its buttons sit in the middle
/// of the bar's height, from the right. They used to hang under the line with twice the gap below.
#[test]
+79 -3
View File
@@ -1901,6 +1901,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let filter = app.ui.layer_filter.clone();
let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default();
crate::layer_row_ui::begin(ui.ctx());
// The drawn rows, top to bottom (#1721): the space between them and below the last
// one is a drop zone too.
let mut drawn: Vec<(LayerId, Rect)> = Vec::new();
for &(depth, l) in &rows {
// Select › Isolate Layers.
if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) {
@@ -1921,7 +1924,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 };
let top = ui.cursor().top();
layer_row(app, &ctx, ui, &doc, l, depth, row, &mut actions);
drawn.push((l.id, layer_row(app, &ctx, ui, &doc, l, depth, row, &mut actions)));
if reveal == Some(l.id) {
crate::layer_reveal::scroll_to_row(ui, top);
}
@@ -1930,6 +1933,17 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
crate::smart_ui::filter_rows(app, ui, l, depth, &mut actions);
}
// #1721: the list takes drops everywhere, not only on a row (Photoshop): the space
// between two rows lands above the lower one, the empty space below the bottom-most
// row below it. Both copy with ⌥ held, move without.
for w in drawn.windows(2) {
let gap = Rect::from_min_max(pos2(w[0].1.left(), w[0].1.bottom()), pos2(w[0].1.right(), w[1].1.top()));
layer_drop_zone(app, &ctx, ui, gap, (w[1].0, w[1].1), "above", &mut actions);
}
if let Some(&(last, last_rect)) = drawn.last() {
let below = Rect::from_min_max(pos2(last_rect.left(), last_rect.bottom()), pos2(last_rect.right(), ui.clip_rect().bottom()));
layer_drop_zone(app, &ctx, ui, below, (last, last_rect), "below", &mut actions);
}
// ⌥-click the line between two layers: clip / release the upper one (#967).
crate::clip_line_ui::show(ui, &doc, &mut actions);
// A rename whose row is gone (deleted, filtered out, inside a closed group) ends,
@@ -2195,7 +2209,7 @@ fn layer_row(
depth: usize,
row: RowSel,
actions: &mut Vec<(String, Value)>,
) {
) -> Rect {
let selected = row.selected;
let t = Tokens::get(ctx);
// Photoshop's default (medium) thumbnails: 32 pt rows.
@@ -2212,7 +2226,7 @@ fn layer_row(
// otherwise lay out names, icons and thumbnails every frame. Group rows stay whole: their
// disclosure triangle is a widget (accessibility, scroll-to).
if !ui.is_rect_visible(rect) && !l.is_group() && !resp.context_menu_opened() && crate::layer_row_ui::renaming(ctx) != Some(l.id.0) {
return;
return rect;
}
let painter = ui.painter_at(rect.expand(1.0));
if t.pro {
@@ -2416,6 +2430,7 @@ fn layer_row(
actions.push(done);
}
});
rect
}
/// Screen rect and UVs for a layer thumbnail: the document-shaped part of the square cell and of
@@ -3122,6 +3137,31 @@ fn layer_drag_and_drop(
if copy {
ctx.set_cursor_icon(egui::CursorIcon::Copy);
}
// #1721: with ⌥ held the row also takes the drop onto itself — the only drop target a
// single-layer document has. The copy lands above or below it (never into a group's own
// centre). A multi-selection is left alone: the engine copies it beside each of its own
// layers, not onto one of them.
let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default();
let in_multi = selected.len() > 1 && selected.contains(&l.id);
if copy
&& !in_multi
&& let Some(p) = pointer.filter(|p| rect.contains(*p))
{
let position = if (p.y - rect.top()) / rect.height() < 0.5 { "above" } else { "below" };
let painter = ui.painter();
if position == "above" {
painter.line_segment([rect.left_top(), rect.right_top()], Stroke::new(2.0, t.accent));
} else {
painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(2.0, t.accent));
}
if released {
let mut payload = layer_drop_payload(dragged, l.id, position, &selected);
if let Some(o) = payload.as_object_mut() {
o.insert("copy".into(), json!(true));
}
actions.push(("layer.moveTo".into(), payload));
}
}
return;
}
let Some(p) = pointer else { return };
@@ -3158,6 +3198,42 @@ fn layer_drag_and_drop(
}
}
/// #1721: the drop zones no row owns — the space between two rows and the empty space below the
/// bottom-most one. The drop lands on `target` at `position` (`above` or `below` its row): a copy
/// with ⌥ held on release (Photoshop), a move without. A multi-selection dropped on the row it
/// was grabbed from is left alone: the engine can only copy it beside each of its own layers.
fn layer_drop_zone(
app: &PhotocraftApp,
ctx: &egui::Context,
ui: &egui::Ui,
zone: Rect,
target: (LayerId, Rect),
position: &'static str,
actions: &mut Vec<(String, Value)>,
) {
let Some(dragged) = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("layer-drag"))) else { return };
let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default();
if target.0 == LayerId(dragged) && selected.len() > 1 && selected.contains(&target.0) {
return;
}
let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return };
if !zone.contains(p) {
return;
}
let t = Tokens::get(ctx);
let line = if position == "above" { [target.1.left_top(), target.1.right_top()] } else { [target.1.left_bottom(), target.1.right_bottom()] };
ui.painter().line_segment(line, Stroke::new(2.0, t.accent));
if ctx.input(|i| i.pointer.any_released()) {
let mut payload = layer_drop_payload(dragged, target.0, position, &selected);
if ctx.input(|i| i.modifiers.alt)
&& let Some(o) = payload.as_object_mut()
{
o.insert("copy".into(), json!(true));
}
actions.push(("layer.moveTo".into(), payload));
}
}
/// 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>);