From 32dabdb5fa1fd628968d235c1d99b1dae2e3e22a Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 05:40:16 +0700 Subject: [PATCH] M1: layers panel drag polish: edge scrolling, spring-open groups, ghost label (#37) Dragging layers in the panel now scrolls the list near its top and bottom edges, opens a collapsed frame or group after holding over it, shows the dragged layer's name (or "N layers") by the pointer, and Escape cancels. A layer can no longer be offered as a drop target inside itself. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/layers.rs | 76 +++++++++++++-- crates/ui-egui/src/layers_drag_tests.rs | 120 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + 4 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/layers_drag_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 43b5582..7a9d97b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -146,7 +146,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). |---|---|---|---|---| | Canvas & navigation | 6 | 65% | pixel preview, frame guides, perf at scale | 6–10 | | Selection & transform | 8 | 64% | scale tool polish, measure hover polish | 8–12 | -| Layers panel | 5 | 70% | drag polish (auto-scroll while dragging, drop into collapsed groups), keyboard row navigation | 4–6 | +| Layers panel | 5 | 76% | keyboard row navigation, drag between pages | 2–4 | | Shapes & vector | 8 | 60% | variable width, shape builder, multi-point transform box, point snapping, live boolean editing, arc handles on canvas | 12–18 | | 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 52425af..8b594fd 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -408,6 +408,19 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let mut rename_done: Option<(NodeId, String)> = None; let mut hovered_row: Option = None; let drag_id = Id::new("layer_drag"); + // 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(), + }; + // 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; // Only the rows in view are laid out and drawn; the rest is empty scroll space. egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { @@ -426,7 +439,24 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { crate::widgets::label(ui, hint, t.text_secondary); }); } - let dragging: Option = ui.data(|d| d.get_temp(drag_id)); + // Dragging near the list's top or bottom edge scrolls it, faster closer to the edge. + if dragging.is_some() + && let Some(p) = ui.input(|i| i.pointer.hover_pos()) + { + const EDGE: f32 = 28.0; + let view = ui.clip_rect(); + let dy = if p.y < view.min.y + EDGE { + (view.min.y + EDGE - p.y).min(EDGE) + } else if p.y > view.max.y - EDGE { + -(p.y - (view.max.y - EDGE)).min(EDGE) + } else { + 0.0 + }; + if dy != 0.0 && view.x_range().contains(p.x) { + ui.scroll_with_delta(vec2(0.0, dy * 0.5)); + ui.ctx().request_repaint(); + } + } let first = ((viewport.min.y / pitch).floor().max(0.0) as usize).min(rows.len()); let last = ((viewport.max.y / pitch).ceil().max(0.0) as usize).saturating_add(1).min(rows.len()); let band = Rect::from_x_y_ranges(ui.max_rect().x_range(), (top + first as f32 * pitch)..=(top + last as f32 * pitch)); @@ -551,8 +581,8 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if resp.drag_started() { ui.data_mut(|d| d.insert_temp(drag_id, row.id)); } - if let Some(dg) = dragging - && dg != row.id + if dragging.is_some() + && !blocked(row.id) && let Some(p) = ui.input(|i| i.pointer.hover_pos()) && r.contains(p) { @@ -572,6 +602,23 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { }; if zone == 0 { ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); + // Holding over a collapsed group or frame opens it, to drop deeper inside. + if row.has_kids && !app.ui.expanded.contains(&row.id) { + let hold_id = Id::new("layer_drag_hold"); + let now = ui.input(|i| i.time); + match ui.data(|d| d.get_temp::<(NodeId, f64)>(hold_id)) { + Some((id, since)) if id == row.id => { + if now - since > 0.6 { + toggle = Some(row.id); + ui.data_mut(|d| d.remove::<(NodeId, f64)>(hold_id)); + } + } + _ => { + ui.data_mut(|d| d.insert_temp(hold_id, (row.id, now))); + } + } + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } } else { ui.painter().line_segment([pos2(x0, y), pos2(rr.max.x, y)], Stroke::new(2.0, ACCENT)); } @@ -586,11 +633,26 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { app.run("select.none", json!({})); } }); + // The dragged layers' name follows the pointer. + if let (Some(dg), Some(p)) = (dragging, ui.input(|i| i.pointer.hover_pos())) { + let label = match moving.len() { + 1 => doc.node(dg).map(|n| n.name.clone()).unwrap_or_default(), + n => format!("{n} layers"), + }; + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::new("layer_drag_ghost"))); + let galley = painter.layout_no_wrap(label, regular(11.0), t.text); + let r = Rect::from_min_size(p + vec2(14.0, 10.0), galley.size() + vec2(16.0, 8.0)); + painter.rect_filled(r, CornerRadius::same(5), t.panel); + painter.rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, ACCENT), egui::StrokeKind::Inside); + painter.galley(r.min + vec2(8.0, 4.0), galley, t.text); + } if ui.input(|i| i.pointer.any_released()) { - let dragged: Option = ui.data(|d| d.get_temp(drag_id)); - ui.data_mut(|d| d.remove::(drag_id)); - if let (Some(dg), Some((target, zone))) = (dragged, drop) { - let ids = if sel.contains(&dg) { sel.clone() } else { vec![dg] }; + ui.data_mut(|d| { + d.remove::(drag_id); + d.remove::<(NodeId, f64)>(Id::new("layer_drag_hold")); + }); + if let (Some(_), Some((target, zone))) = (dragging, drop) { + let ids = moving.clone(); match zone { 0 => { app.run("node.reparent", json!({"ids": ids, "parent": target})); diff --git a/crates/ui-egui/src/layers_drag_tests.rs b/crates/ui-egui/src/layers_drag_tests.rs new file mode 100644 index 0000000..f8c1a1a --- /dev/null +++ b/crates/ui-egui/src/layers_drag_tests.rs @@ -0,0 +1,120 @@ +//! Dragging rows in the layers panel, driven through egui headless: holding over a collapsed +//! frame opens it, a layer can't be dropped into itself, Escape cancels, and the list scrolls at +//! its edges. + +use egui::{Event, Pos2, RawInput, Rect, vec2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, NoServices}; + +struct Ui { + ctx: egui::Context, + app: FigApp, + time: f64, +} + +impl Ui { + fn new(s: Session) -> Self { + let mut u = Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 }; + for _ in 0..3 { + u.frame(vec![]); + } + u + } + + fn frame(&mut self, events: Vec) { + self.time += 1.0 / 60.0; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + let app = &mut self.app; + self.ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear(); + } + + /// The middle of `id`'s row in the layers panel (found by hovering down the panel). + fn row_at(&mut self, id: NodeId) -> Pos2 { + let mut hits = Vec::new(); + for y in (60..900).step_by(2) { + let p = Pos2::new(150.0, y as f32); + // The panel only sets the hover when a row is under the pointer. + self.app.session.ds_mut().unwrap().hover = None; + self.frame(vec![Event::PointerMoved(p)]); + if self.app.session.ds().unwrap().hover == Some(id) { + hits.push(p.y); + } else if !hits.is_empty() { + break; + } + } + assert!(!hits.is_empty(), "no row for {id:?}"); + Pos2::new(150.0, (hits[0] + hits[hits.len() - 1]) / 2.0) + } + + fn button(&mut self, p: Pos2, pressed: bool) { + self.frame(vec![ + Event::PointerMoved(p), + Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + ]); + } + + /// Press on `from` and move to `to` in small steps (a drag, not yet released). + fn drag_to(&mut self, from: Pos2, to: Pos2) { + self.button(from, true); + for k in 1..=10 { + self.frame(vec![Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))]); + } + } +} + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A frame with one child (collapsed in the panel) and a rectangle beside it. +fn setup() -> (Ui, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let child = id(s.execute("node.create", &json!({"type": "ellipse", "parent": frame, "x": 10, "y": 10, "width": 10, "height": 10})).unwrap()); + let rect = id(s.execute("node.create", &json!({"type": "rectangle", "x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.expanded.clear(); + u.frame(vec![]); + (u, frame, child, rect) +} + +#[test] +fn holding_over_a_collapsed_frame_opens_it_and_dropping_moves_the_layer_in() { + let (mut u, frame, child, rect) = setup(); + let (r, f) = (u.row_at(rect), u.row_at(frame)); + u.drag_to(r, f); + assert!(!u.app.ui.expanded.contains(&frame)); + // Hold for a second over the middle of the frame's row. + for _ in 0..60 { + u.frame(vec![Event::PointerMoved(f)]); + } + assert!(u.app.ui.expanded.contains(&frame), "spring-loaded open"); + u.button(f, false); + let doc = u.app.session.doc().unwrap(); + assert_eq!(doc.node(rect).unwrap().parent, Some(frame), "dropped into the frame"); + assert!(doc.node(frame).unwrap().children.contains(&child)); +} + +#[test] +fn a_layer_cant_drop_into_its_own_child_and_escape_cancels() { + let (mut u, frame, child, rect) = setup(); + u.app.ui.expanded.insert(frame); + u.frame(vec![]); + let (f, c) = (u.row_at(frame), u.row_at(child)); + let parent_before = u.app.session.doc().unwrap().node(frame).unwrap().parent; + u.drag_to(f, c); + u.button(c, false); + assert_eq!(u.app.session.doc().unwrap().node(frame).unwrap().parent, parent_before, "not moved into itself"); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); + // Escape during a drag: releasing over a target then does nothing. + let (r, f) = (u.row_at(rect), u.row_at(frame)); + u.drag_to(r, f); + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + u.button(f, false); + assert_ne!(u.app.session.doc().unwrap().node(rect).unwrap().parent, Some(frame)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4a434b1..f6e3d14 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -38,6 +38,8 @@ pub mod vector_panel; pub mod versions; pub mod widgets; +#[cfg(test)] +mod layers_drag_tests; #[cfg(test)] mod perf_tests;