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 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 05:40:16 +07:00 committed by GitHub
parent ce317cdf42
commit 32dabdb5fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 192 additions and 8 deletions

View File

@ -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 |

View File

@ -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<NodeId> = 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::<NodeId>(drag_id));
}
let dragging: Option<NodeId> = ui.data(|d| d.get_temp(drag_id));
// What a drag moves: the selection when the dragged row is in it.
let moving: Vec<NodeId> = 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<NodeId> = 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<NodeId> = ui.data(|d| d.get_temp(drag_id));
ui.data_mut(|d| d.remove::<NodeId>(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::<NodeId>(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}));

View File

@ -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<Event>) {
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));
}

View File

@ -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;