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:
parent
ce317cdf42
commit
32dabdb5fa
|
|
@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Strokes | 3 | 70% | stroke styles, variable width, arrowhead previews in the menus | 3–5 |
|
||||||
|
|
|
||||||
|
|
@ -408,6 +408,19 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||||
let mut rename_done: Option<(NodeId, String)> = None;
|
let mut rename_done: Option<(NodeId, String)> = None;
|
||||||
let mut hovered_row: Option<NodeId> = None;
|
let mut hovered_row: Option<NodeId> = None;
|
||||||
let drag_id = Id::new("layer_drag");
|
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;
|
let mut drop: Option<(NodeId, i8)> = None;
|
||||||
// Only the rows in view are laid out and drawn; the rest is empty scroll space.
|
// 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| {
|
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);
|
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 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 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));
|
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() {
|
if resp.drag_started() {
|
||||||
ui.data_mut(|d| d.insert_temp(drag_id, row.id));
|
ui.data_mut(|d| d.insert_temp(drag_id, row.id));
|
||||||
}
|
}
|
||||||
if let Some(dg) = dragging
|
if dragging.is_some()
|
||||||
&& dg != row.id
|
&& !blocked(row.id)
|
||||||
&& let Some(p) = ui.input(|i| i.pointer.hover_pos())
|
&& let Some(p) = ui.input(|i| i.pointer.hover_pos())
|
||||||
&& r.contains(p)
|
&& r.contains(p)
|
||||||
{
|
{
|
||||||
|
|
@ -572,6 +602,23 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||||
};
|
};
|
||||||
if zone == 0 {
|
if zone == 0 {
|
||||||
ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside);
|
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 {
|
} else {
|
||||||
ui.painter().line_segment([pos2(x0, y), pos2(rr.max.x, y)], Stroke::new(2.0, ACCENT));
|
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!({}));
|
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()) {
|
if ui.input(|i| i.pointer.any_released()) {
|
||||||
let dragged: Option<NodeId> = ui.data(|d| d.get_temp(drag_id));
|
ui.data_mut(|d| {
|
||||||
ui.data_mut(|d| d.remove::<NodeId>(drag_id));
|
d.remove::<NodeId>(drag_id);
|
||||||
if let (Some(dg), Some((target, zone))) = (dragged, drop) {
|
d.remove::<(NodeId, f64)>(Id::new("layer_drag_hold"));
|
||||||
let ids = if sel.contains(&dg) { sel.clone() } else { vec![dg] };
|
});
|
||||||
|
if let (Some(_), Some((target, zone))) = (dragging, drop) {
|
||||||
|
let ids = moving.clone();
|
||||||
match zone {
|
match zone {
|
||||||
0 => {
|
0 => {
|
||||||
app.run("node.reparent", json!({"ids": ids, "parent": target}));
|
app.run("node.reparent", json!({"ids": ids, "parent": target}));
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,8 @@ pub mod vector_panel;
|
||||||
pub mod versions;
|
pub mod versions;
|
||||||
pub mod widgets;
|
pub mod widgets;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod layers_drag_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod perf_tests;
|
mod perf_tests;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue