From 9ccea32651de480844384307a998557c262bce96 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Sat, 10 Oct 2026 20:23:22 +0200 Subject: [PATCH] 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 --- crates/ui-egui/src/layer_row_ui/tests.rs | 71 ++++++++++++++++++++ crates/ui-egui/src/panels.rs | 82 +++++++++++++++++++++++- 2 files changed, 150 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/layer_row_ui/tests.rs b/crates/ui-egui/src/layer_row_ui/tests.rs index 796084b42f..791095c1db 100644 --- a/crates/ui-egui/src/layer_row_ui/tests.rs +++ b/crates/ui-egui/src/layer_row_ui/tests.rs @@ -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::>(); + 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::>(); + // 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] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 09fcb66208..1ec9cbabbc 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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::(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);