diff --git a/ROADMAP.md b/ROADMAP.md index ccbc573..1afcd38 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/engine/src/al_drag_tests.rs b/crates/engine/src/al_drag_tests.rs new file mode 100644 index 0000000..528fdd9 --- /dev/null +++ b/crates/engine/src/al_drag_tests.rs @@ -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) { + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap()); + let kids: Vec = + (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"); +} diff --git a/crates/engine/src/drop.rs b/crates/engine/src/drop.rs index ab36613..1bc67a5 100644 --- a/crates/engine/src/drop.rs +++ b/crates/engine/src/drop.rs @@ -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 = 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::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); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7bd01f8..3160e1e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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)] diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 76ad734..67f579b 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -218,6 +218,8 @@ pub struct Overlay { pub drop_target: Option, /// 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, @@ -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, .. } => { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8257caa..dd25ee1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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)