M2: auto layout drag: wrap rows and grid cells (#27)
- Dragging a child of a wrapping row layout inserts by visual row and position (rows are found from the flow, so the insertion line spans the right row), instead of by x alone. - Dragging a child of a grid layout places it in the cell under the pointer (nearest cell over gaps), with a dashed cell highlight while dragging; one undo step. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b8687d7c0d
commit
48d5964941
|
|
@ -54,8 +54,8 @@ dimension measured side by side.
|
|||
3. **Auto layout depth**: *landed:* on-canvas padding/gap handles (also wrapped-row and grid
|
||||
column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, hugging text
|
||||
wraps at its max), ignore auto layout (absolute position) with constraints, text baseline
|
||||
alignment, grid tracks (fixed/hug/`fr`), grid cells and spans, wrap row gap UI. *Left:*
|
||||
grid/wrap-aware drag-reorder and drag-to-cell, nested hug/fill edge cases. 6–10 h.
|
||||
alignment, grid tracks (fixed/hug/`fr`), grid cells and spans, wrap row gap UI, wrap-aware
|
||||
drag-reorder (by row) and dragging into a grid cell. *Left:* nested hug/fill edge cases. 4–7 h.
|
||||
4. **Components depth**: *landed:* component properties (edit/delete/bind) with the properties
|
||||
panel, add variant, instance swap UI, per-layer overrides with reset, restore main, instance
|
||||
structure protection; Assets panel with live thumbnails, search and drag-to-canvas (drops into
|
||||
|
|
@ -143,7 +143,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|
|||
| Strokes | 3 | 70% | stroke styles, variable width, arrowhead previews in the menus | 3–5 |
|
||||
| Effects | 4 | 70% | glass/texture fidelity, shadow spread on non-rects exactness | 4–7 |
|
||||
| Frames, groups, sections, masks | 5 | 75% | hiding section contents UI, section-level prototype/dev affordances | 2–4 |
|
||||
| Auto layout & constraints | 9 | 60% | grid/wrap-aware drag-reorder, drag to grid cell, nested hug/fill edge cases | 6–10 |
|
||||
| Auto layout & constraints | 9 | 65% | nested hug/fill edge cases, drag-reorder across nested auto layout frames | 4–7 |
|
||||
| Text | 8 | 60% | right-to-left/bidi, text style picker depth, axis hover preview | 8–12 |
|
||||
| Components & variants | 9 | 60% | libraries, slots, nested override edge cases | 10–15 |
|
||||
| Styles & variables | 5 | 60% | aliasing UI, scoping, binding padding/font size/opacity/text, string and boolean bindings in the panel | 6–10 |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
//! Dragging children of wrapping and grid auto layout frames.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
fn id(v: serde_json::Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap();
|
||||
}
|
||||
|
||||
/// A frame with six 50×30 children in auto layout of `mode`.
|
||||
fn frame_with_kids(s: &mut Session, props: serde_json::Value) -> (NodeId, Vec<NodeId>) {
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap());
|
||||
let kids: Vec<NodeId> =
|
||||
(0..6).map(|i| id(s.execute("shape.rectangle", &json!({"x": i * 60, "y": 0, "width": 50, "height": 30, "parent": f})).unwrap())).collect();
|
||||
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
|
||||
s.execute("node.set", &json!({"id": f, "props": props})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
(f, kids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_drag_reorders_by_row_and_position() {
|
||||
let mut s = Session::new();
|
||||
let (f, kids) = frame_with_kids(
|
||||
&mut s,
|
||||
json!({"layoutMode": "horizontal", "wrap": true, "width": 200, "sizingH": "fixed", "gap": 10, "crossGap": 10, "padding": 0}),
|
||||
);
|
||||
let b = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap();
|
||||
// Three per row: kid 4 sits in the second row.
|
||||
assert!(b(&s, kids[3]).y0 > b(&s, kids[0]).y0, "wrapped into two rows");
|
||||
// Drag the first child into the second row, just before kid 5.
|
||||
let from = b(&s, kids[0]).center();
|
||||
let k5 = b(&s, kids[5]);
|
||||
drag(&mut s, (from.x, from.y), (k5.x0 + 5.0, k5.center().y));
|
||||
let order = s.doc().unwrap().node(f).unwrap().children.clone();
|
||||
assert_eq!(order, vec![kids[1], kids[2], kids[3], kids[4], kids[0], kids[5]], "{order:?}");
|
||||
// Right of a row's last child: after it.
|
||||
let from = b(&s, kids[5]).center();
|
||||
let k1 = b(&s, kids[2]);
|
||||
drag(&mut s, (from.x, from.y), (k1.x1 + 4.0, k1.center().y));
|
||||
let order = s.doc().unwrap().node(f).unwrap().children.clone();
|
||||
assert_eq!(order.get(..3), Some(&[kids[1], kids[2], kids[5]][..]), "{order:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_drag_takes_the_cell_under_the_pointer() {
|
||||
let mut s = Session::new();
|
||||
let (f, kids) = frame_with_kids(&mut s, json!({"layoutMode": "grid", "gridColumns": 3, "gridRows": 2}));
|
||||
let geometry = figcraft_layout::grid_geometry(s.doc().unwrap(), f).unwrap();
|
||||
let (c2, r1) = (geometry.columns[2], geometry.rows[1]);
|
||||
let to = s.doc().unwrap().world_transform(f) * figcraft_geom::Point::new((c2.0 + c2.1) / 2.0, (r1.0 + r1.1) / 2.0);
|
||||
let from = s.doc().unwrap().world_bounds(kids[0]).unwrap().center();
|
||||
drag(&mut s, (from.x, from.y), (to.x, to.y));
|
||||
let cl = s.doc().unwrap().node(kids[0]).unwrap().child_layout.clone();
|
||||
assert_eq!((cl.grid_column, cl.grid_row), (Some(2), Some(1)));
|
||||
assert!(s.tool.overlay.drop_cell.is_none(), "the cell indicator clears on release");
|
||||
s.undo().unwrap();
|
||||
let cl = s.doc().unwrap().node(kids[0]).unwrap().child_layout.clone();
|
||||
assert_eq!((cl.grid_column, cl.grid_row), (None, None), "one undo step");
|
||||
}
|
||||
|
|
@ -22,8 +22,12 @@ pub struct DropSlot {
|
|||
/// draw there.
|
||||
pub fn auto_layout_slot(doc: &Document, parent: NodeId, p: Point, exclude: &[NodeId]) -> Option<(usize, Option<[f64; 4]>)> {
|
||||
let pn = doc.node(parent)?;
|
||||
let vertical = pn.layout.as_ref()?.mode == LayoutMode::Vertical;
|
||||
let layout = pn.layout.as_ref()?;
|
||||
let vertical = layout.mode == LayoutMode::Vertical;
|
||||
let kids: Vec<NodeId> = pn.children.iter().copied().filter(|c| !exclude.contains(c)).collect();
|
||||
if layout.wrap && !vertical && layout.mode != LayoutMode::Grid {
|
||||
return Some(wrap_slot(doc, &kids, p));
|
||||
}
|
||||
let pos = if vertical { p.y } else { p.x };
|
||||
let idx = kids
|
||||
.iter()
|
||||
|
|
@ -41,6 +45,74 @@ pub fn auto_layout_slot(doc: &Document, parent: NodeId, p: Point, exclude: &[Nod
|
|||
Some((idx, line))
|
||||
}
|
||||
|
||||
/// The slot in a wrapping row layout: the visual row under the pointer (children wrap in flow
|
||||
/// order, so a new row starts where a child sits left of the one before), then the position in it.
|
||||
fn wrap_slot(doc: &Document, kids: &[NodeId], p: Point) -> (usize, Option<[f64; 4]>) {
|
||||
let bounds: Vec<(usize, figcraft_geom::Rect)> = kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, b))).collect();
|
||||
let mut rows: Vec<Vec<(usize, figcraft_geom::Rect)>> = Vec::new();
|
||||
for (i, b) in bounds {
|
||||
match rows.last_mut() {
|
||||
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5) => row.push((i, b)),
|
||||
_ => rows.push(vec![(i, b)]),
|
||||
}
|
||||
}
|
||||
let span =
|
||||
|row: &[(usize, figcraft_geom::Rect)]| row.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), (_, b)| (lo.min(b.y0), hi.max(b.y1)));
|
||||
// The row containing the pointer, else the nearest one.
|
||||
let Some(row) = rows.iter().min_by(|a, b| {
|
||||
let d = |r: &[(usize, figcraft_geom::Rect)]| {
|
||||
let (lo, hi) = span(r);
|
||||
if p.y < lo {
|
||||
lo - p.y
|
||||
} else if p.y > hi {
|
||||
p.y - hi
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
d(a).total_cmp(&d(b))
|
||||
}) else {
|
||||
return (0, None);
|
||||
};
|
||||
let (y0, y1) = span(row);
|
||||
match row.iter().find(|(_, b)| p.x < b.center().x) {
|
||||
Some((i, b)) => (*i, Some([b.x0 - 2.0, y0, b.x0 - 2.0, y1])),
|
||||
None => match row.last() {
|
||||
Some((i, b)) => (i + 1, Some([b.x1 + 2.0, y0, b.x1 + 2.0, y1])),
|
||||
None => (0, None),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid cell of auto layout grid `parent` under page point `p` (the nearest one): (column,
|
||||
/// row, the cell's page-space bounds).
|
||||
pub fn grid_cell_at(doc: &Document, parent: NodeId, p: Point) -> Option<(u32, u32, [f64; 4])> {
|
||||
let g = figcraft_layout::grid_geometry(doc, parent)?;
|
||||
let lp = doc.world_transform(parent).inverse() * p;
|
||||
let nearest = |tracks: &[(f64, f64)], v: f64| {
|
||||
tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by(|(_, a), (_, b)| {
|
||||
let d = |t: &(f64, f64)| {
|
||||
if v < t.0 {
|
||||
t.0 - v
|
||||
} else if v > t.1 {
|
||||
v - t.1
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
d(a).total_cmp(&d(b))
|
||||
})
|
||||
.map(|(i, t)| (i, *t))
|
||||
};
|
||||
let (ci, (cx0, cx1)) = nearest(&g.columns, lp.x)?;
|
||||
let (ri, (ry0, ry1)) = nearest(&g.rows, lp.y)?;
|
||||
let r = figcraft_geom::transform_rect_bbox(doc.world_transform(parent), figcraft_geom::Rect::new(cx0, ry0, cx1, ry1));
|
||||
Some((u32::try_from(ci).ok()?, u32::try_from(ri).ok()?, [r.x0, r.y0, r.x1, r.y1]))
|
||||
}
|
||||
|
||||
/// Where a drop at page point `p` lands.
|
||||
pub fn slot(doc: &Document, page: NodeId, p: Point) -> DropSlot {
|
||||
let parent = frame_at(doc, page, p, &[]).unwrap_or(page);
|
||||
|
|
|
|||
|
|
@ -751,6 +751,8 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
|||
doc.take_dirty();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod al_drag_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_tests;
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -218,6 +218,8 @@ pub struct Overlay {
|
|||
pub drop_target: Option<NodeId>,
|
||||
/// Auto layout insertion indicator (x0, y0, x1, y1).
|
||||
pub insert_line: Option<[f64; 4]>,
|
||||
/// Auto layout grid cell a drag would drop into (x0, y0, x1, y1).
|
||||
pub drop_cell: Option<[f64; 4]>,
|
||||
/// Vector edit mode: what the pointer is over, the lasso being drawn and the closed area the
|
||||
/// paint bucket would fill (page-space polygons).
|
||||
pub vector_hover: Option<crate::vectoredit::VHit>,
|
||||
|
|
@ -636,6 +638,7 @@ impl Session {
|
|||
let dup = *duplicate;
|
||||
let page = self.page()?;
|
||||
let mut insert_line = None;
|
||||
let mut drop_cell = None;
|
||||
let mut drop_target = None;
|
||||
self.preview(|doc, sel| {
|
||||
let mut moving = ids.clone();
|
||||
|
|
@ -659,6 +662,17 @@ impl Session {
|
|||
if let (Some(pp), true) = (al_parent, flow) {
|
||||
let pb = doc.world_bounds(pp).unwrap_or_default();
|
||||
let still_inside = pb.inflate(8.0, 8.0).contains(ev.p());
|
||||
let grid = doc.node(pp).and_then(|n| n.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Grid);
|
||||
if still_inside && grid {
|
||||
// Grids: the dragged layer takes the cell under the pointer.
|
||||
if let (Some(first), Some((col, row, rect))) = (moving.first().copied(), crate::drop::grid_cell_at(doc, pp, ev.p())) {
|
||||
let cl = &mut doc.get_mut(first)?.child_layout;
|
||||
cl.grid_column = Some(col);
|
||||
cl.grid_row = Some(row);
|
||||
drop_cell = Some(rect);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if still_inside {
|
||||
let (idx, line_at) = crate::drop::auto_layout_slot(doc, pp, ev.p(), &moving).unwrap_or((0, None));
|
||||
insert_line = line_at;
|
||||
|
|
@ -684,6 +698,7 @@ impl Session {
|
|||
})?;
|
||||
self.tool.overlay.guides = guides;
|
||||
self.tool.overlay.insert_line = insert_line;
|
||||
self.tool.overlay.drop_cell = drop_cell;
|
||||
self.tool.overlay.drop_target = drop_target;
|
||||
self.tool.gesture = g.clone();
|
||||
if let Gesture::Move { moved, .. } = &mut self.tool.gesture {
|
||||
|
|
@ -1066,6 +1081,7 @@ impl Session {
|
|||
self.tool.overlay.marquee = None;
|
||||
self.tool.overlay.guides.clear();
|
||||
self.tool.overlay.insert_line = None;
|
||||
self.tool.overlay.drop_cell = None;
|
||||
let drop_target = self.tool.overlay.drop_target.take();
|
||||
match g {
|
||||
Gesture::Move { ids, moved, duplicate, .. } => {
|
||||
|
|
|
|||
|
|
@ -559,6 +559,12 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
|
|||
if let Some([x0, y0, x1, y1]) = ov.insert_line {
|
||||
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 2.0));
|
||||
}
|
||||
if let Some([x0, y0, x1, y1]) = ov.drop_cell {
|
||||
let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1));
|
||||
painter.rect_filled(r, CornerRadius::same(2), ACCENT.gamma_multiply(0.12));
|
||||
let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
|
||||
painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.5), 4.0, 3.0));
|
||||
}
|
||||
// Vector edit mode.
|
||||
if let Some(ve) = ds.vector_edit.clone()
|
||||
&& let Some(o) = figcraft_engine::vectoredit::overlay(&doc, &ve)
|
||||
|
|
|
|||
Loading…
Reference in New Issue