Layers panel: drop layers on the footer's New Layer, New Group and Delete buttons (#736) (#965)

As in Photoshop, dragging a layer row onto New Layer duplicates it, onto New
Group groups it and onto Delete deletes it. A row that is part of the
selection carries the whole selection; any other row goes alone. The button
under the pointer is highlighted during the drag.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-08 03:17:29 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 11f6f00ccc
commit 997acf5885
2 changed files with 85 additions and 3 deletions
+42
View File
@@ -222,3 +222,45 @@ fn dragging_down_the_eyes_sweeps_visibility_without_reordering() {
assert!(visible(&h, rows[1].layer));
}
}
/// #736: a layer row dropped on the footer's New Layer button is duplicated, on New Group it is
/// grouped, on Delete it is deleted; a row outside the selection goes alone.
#[test]
fn dropping_a_row_on_the_footer_buttons_duplicates_groups_and_deletes() {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
for i in 0..3 {
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<_>>();
let drag_to = |h: &mut Harness<'static, PhotocraftApp>, row: usize, label: &str| {
let from = recorded(&h.ctx)[row].row.center();
let to = h.get_by_label(label).rect().center();
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::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
};
// Rows top to bottom: L2 (selected), L1, L0, Background. L1 isn't selected: it goes alone.
drag_to(&mut h, 1, "Create a new layer");
assert_eq!(names(&h), ["Background", "L0", "L1", "L1 copy", "L2"]);
let steps = h.state().session.active().unwrap().history.past_len();
// Rows: L2, L1 copy (now selected), L1, L0, Background.
drag_to(&mut h, 3, "Create a new group");
let doc = &h.state().session.active().unwrap().doc;
let group = doc.layers.iter().find(|l| l.is_group()).expect("a group");
let LayerContent::Group(g) = &group.content else { panic!("not a group") };
assert_eq!(g.children.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["L0"]);
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "one undo step");
let n = h.state().session.active().unwrap().doc.layers.len();
drag_to(&mut h, 0, "Delete layer");
assert!(!names(&h).iter().any(|n| n == "L2"));
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), n - 1);
}
+43 -3
View File
@@ -1526,6 +1526,12 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
actions.push(done);
}
});
// A layer being dragged can also be dropped on the footer's Delete, New Layer and Group
// buttons (#736); read the drag before it ends.
let footer_drag = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("layer-drag"))).map(|id| {
let in_selection = selection.iter().any(|s| s.0 == id);
(id, in_selection)
});
// 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")));
@@ -1536,15 +1542,21 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if icons::button(ui, "trash", 26.0, false, tl!("Delete layer")).clicked() {
let trash = icons::button(ui, "trash", 26.0, false, tl!("Delete layer"));
if trash.clicked() {
actions.push(("layer.delete".into(), json!({})));
}
if icons::button(ui, "square-plus", 26.0, false, &crate::shortcuts::tip_label(app, "Create a new layer", "layer.new.layer")).clicked() {
actions.extend(footer_drop(ui, &trash, footer_drag, "layer.delete"));
let new_layer = icons::button(ui, "square-plus", 26.0, false, &crate::shortcuts::tip_label(app, "Create a new layer", "layer.new.layer"));
if new_layer.clicked() {
actions.push(("layer.new.layer".into(), json!({})));
}
if icons::button(ui, "folder", 26.0, false, tl!("Create a new group")).clicked() {
actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate"));
let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group"));
if group.clicked() {
actions.push(("layer.new.group".into(), json!({})));
}
actions.extend(footer_drop(ui, &group, footer_drag, "layer.groupLayers"));
let adj = icons::button(ui, "contrast", 26.0, false, tl!("Create new fill or adjustment layer"));
egui::Popup::menu(&adj).show(|ui| {
ui.set_min_width(190.0);
@@ -1622,6 +1634,34 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
}
/// A layer row dragged onto a Layers panel footer button, as in Photoshop: onto Delete deletes it,
/// onto New Layer duplicates it, onto New Group groups it. A row that is part of the selection
/// carries the whole selection; any other row goes alone. Highlights the button while over it and
/// returns the command on release.
fn footer_drop(ui: &egui::Ui, button: &egui::Response, drag: Option<(u64, bool)>, command: &str) -> Option<(String, Value)> {
// Named for screen readers (and tests) after the command a drop runs.
button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, footer_label(command)));
let (layer, in_selection) = drag?;
let p = ui.ctx().input(|i| i.pointer.interact_pos())?;
if !button.rect.contains(p) {
return None;
}
let t = Tokens::get(ui.ctx());
ui.painter().rect_stroke(button.rect, t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside);
if !ui.ctx().input(|i| i.pointer.any_released()) {
return None;
}
Some((command.into(), if in_selection { json!({}) } else { json!({"layer": layer}) }))
}
fn footer_label(command: &str) -> &'static str {
match command {
"layer.delete" => tl!("Delete layer"),
"layer.duplicate" => tl!("Create a new layer"),
_ => tl!("Create a new group"),
}
}
/// Photoshop's italic "fx" footer button (no icon-font equivalent).
fn fx_button(ui: &mut egui::Ui, size: f32, tip: &str) -> egui::Response {
let t = Tokens::get(ui.ctx());