From 5c6fb08b494c7db4583cf9e2017adc58e630fedb Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 06:32:01 +0700 Subject: [PATCH] M1: drag layers onto a page in the Pages list (#46) While dragging layer rows, another page in the Pages list highlights as a drop target; dropping moves the dragged layers (the selection when the dragged row is in it) to that page through object.moveToPage. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/layers.rs | 44 +++++++++++++++++++++---- crates/ui-egui/src/layers_drag_tests.rs | 29 ++++++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c67e0a71..43f7b1c4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -152,7 +152,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). |---|---|---|---|---| | Canvas & navigation | 6 | 72% | frame guides, 2x pixel preview | 3–5 | | Selection & transform | 8 | 64% | scale tool polish, measure hover polish | 8–12 | -| Layers panel | 5 | 76% | keyboard row navigation, drag between pages | 2–4 | +| Layers panel | 5 | 79% | keyboard row navigation | 1–2 | | Shapes & vector | 8 | 63% | variable width, multi-point transform box, point snapping, live boolean editing, arc handles on canvas, shape builder on strokes and open paths | 10–15 | | Paint | 7 | 65% | colour profiles (Display P3), paint style picker polish, image adjustment presets | 6–10 | | Strokes | 3 | 70% | stroke styles, variable width, arrowhead previews in the menus | 3–5 | diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 8b594fdd..26a8a641 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -185,14 +185,29 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let Ok(ds) = app.session.ds() else { return }; let pages: Vec<(NodeId, String)> = ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone()))).collect(); let cur = ds.page; + let sel = ds.selection.clone(); let mut select = None; let mut rename_done: Option<(NodeId, String)> = None; + // Layers dragged from the list onto another page move there. + let dragging: Option = ui.data(|d| d.get_temp(layer_drag_id())); + let pointer = ui.input(|i| i.pointer.hover_pos()); + let mut move_to: Option<(NodeId, String)> = None; for (id, name) in pages { let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click()); let r = r.shrink2(vec2(8.0, 0.0)); if resp.hovered() || id == cur { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } + if dragging.is_some() + && id != cur + // The gap between rows counts too. + && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) + { + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); + if ui.input(|i| i.pointer.any_released()) { + move_to = Some((id, name.clone())); + } + } if app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id) { let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); if let Some(new) = @@ -235,6 +250,12 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.rename_page = None; app.run("page.rename", json!({"id": id, "name": new})); } + if let Some((page, name)) = move_to { + let ids = dragged_layers(dragging, &sel); + if app.run("object.moveToPage", json!({"ids": ids, "page": page})).is_some() { + app.toast(format!("Moved to {name}")); + } + } if let Some(p) = select && p != cur { @@ -243,6 +264,20 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { crate::widgets::separator(ui); } +/// The layer row being dragged in the panel (temp data). +fn layer_drag_id() -> Id { + Id::new("layer_drag") +} + +/// What a drag of row `dragging` moves: the selection when the row is in it, else the row. +fn dragged_layers(dragging: Option, sel: &[NodeId]) -> Vec { + match dragging { + Some(dg) if sel.contains(&dg) => sel.to_vec(), + Some(dg) => vec![dg], + None => Vec::new(), + } +} + #[derive(Clone)] pub(crate) struct Row { pub(crate) id: NodeId, @@ -407,18 +442,13 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let mut lock: Option = None; let mut rename_done: Option<(NodeId, String)> = None; let mut hovered_row: Option = None; - let drag_id = Id::new("layer_drag"); + let drag_id = layer_drag_id(); // Escape cancels a drag. if ui.input(|i| i.key_pressed(egui::Key::Escape)) { ui.data_mut(|d| d.remove::(drag_id)); } let dragging: Option = ui.data(|d| d.get_temp(drag_id)); - // What a drag moves: the selection when the dragged row is in it. - let moving: Vec = match dragging { - Some(dg) if sel.contains(&dg) => sel.clone(), - Some(dg) => vec![dg], - None => Vec::new(), - }; + let moving = dragged_layers(dragging, &sel); // A layer can't go into itself or its own descendants. let blocked = |id: NodeId| moving.contains(&id) || doc.ancestors(id).iter().any(|a| moving.contains(a)); let mut drop: Option<(NodeId, i8)> = None; diff --git a/crates/ui-egui/src/layers_drag_tests.rs b/crates/ui-egui/src/layers_drag_tests.rs index f8c1a1a1..a9aaa622 100644 --- a/crates/ui-egui/src/layers_drag_tests.rs +++ b/crates/ui-egui/src/layers_drag_tests.rs @@ -118,3 +118,32 @@ fn a_layer_cant_drop_into_its_own_child_and_escape_cancels() { u.button(f, false); assert_ne!(u.app.session.doc().unwrap().node(rect).unwrap().parent, Some(frame)); } + +#[test] +fn dropping_layers_on_another_page_moves_them_there() { + let (mut u, _frame, _child, rect) = setup(); + let p1 = u.app.session.page().unwrap(); + let p2 = id(u.app.session.execute("page.add", &json!({})).unwrap()); + u.app.session.execute("page.select", &json!({"id": p1})).unwrap(); + u.frame(vec![]); + // Find Page 2's row in the Pages list: the click that switches to it. + let mut at = None; + for y in (90..400).step_by(4) { + let p = Pos2::new(150.0, y as f32); + u.button(p, true); + u.button(p, false); + if u.app.session.page().unwrap() == p2 { + // Rows are 32 high: aim at the middle, past the hit tolerance at its edge. + at = Some(p + vec2(0.0, 12.0)); + u.app.session.execute("page.select", &json!({"id": p1})).unwrap(); + u.frame(vec![]); + break; + } + } + let page_row = at.expect("page 2 row"); + let r = u.row_at(rect); + u.drag_to(r, page_row); + u.button(page_row, false); + let doc = u.app.session.doc().unwrap(); + assert_eq!(doc.ancestors(rect).last().copied(), Some(p2), "moved to page 2"); +}