mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
co-authored by
Brandon Thomas
parent
eab15ffdf6
commit
9ccea32651
@@ -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]
|
||||
|
||||
@@ -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>);
|
||||
|
||||
Reference in New Issue
Block a user