From 475a7da502470fdcbd1b6b6d702e5d2e48affbd2 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmoud Date: Thu, 29 Oct 2026 08:42:52 +0300 Subject: [PATCH] M1: guides snap to layer edges and centres when dragged Dragging a guide, or moving one, now snaps to nearby layer edges and centres on the guide's own axis: within 5 screen pixels, gated by Snap to Objects and suspended with Cmd/Ctrl. Page guides see the page's top-level layers; frame guides see the frame's box, its layout grids and every visible layer inside it, in the frame's own space. Snapping sits in the interaction path (canvas/guides.rs, not guide.add/guide.move) so the commands stay deterministic for the control channel and MCP. Tests: guide_snap_tests.rs in the engine and ui-egui crates; docs/ui-parity.md updated. Signed-off-by: Ahmed Mahmoud --- crates/engine/src/canvas/snap.rs | 61 +++++++++++++++ crates/engine/src/guide_snap_tests.rs | 100 +++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/ui-egui/src/canvas/guides.rs | 35 ++++++--- crates/ui-egui/src/guide_snap_tests.rs | 97 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + docs/ui-parity.md | 5 +- 7 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/guide_snap_tests.rs create mode 100644 crates/ui-egui/src/guide_snap_tests.rs diff --git a/crates/engine/src/canvas/snap.rs b/crates/engine/src/canvas/snap.rs index b68f1fe9..3cd832dc 100644 --- a/crates/engine/src/canvas/snap.rs +++ b/crates/engine/src/canvas/snap.rs @@ -9,6 +9,7 @@ use figcraft_geom::{Affine, Point, Rect, Vec2, transform_rect_bbox}; use serde::Serialize; use crate::guides; +use crate::tools::SNAP_PX; /// A guide line to draw: from (x0, y0) to (x1, y1) in page coordinates, with an optional label /// (a distance) at its middle; `dashed` marks a measurement's extension line. @@ -127,6 +128,48 @@ impl Targets { t } + /// Snap lines for a guide on `owner` (a page or a top-level frame): a page's top-level layers, + /// or a frame's own box, layout grids and descendants, in the owner's own space so a rotated + /// frame's guide snaps along its axes. + pub fn gather_for_guide(doc: &Document, page: NodeId, owner: NodeId) -> Targets { + let space = if owner == page { Affine::IDENTITY } else { doc.world_transform(owner) }; + let to = space.inverse(); + let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite()); + let mut t = Targets { space, ..Default::default() }; + if owner == page { + if let Some(p) = doc.node(page) { + for c in &p.children { + if doc.node(*c).is_some_and(|n| n.visible) + && let Some(b) = bbox(*c) + { + t.add_box(b); + } + } + } + return t; + } + // The frame's own box and its layout grids. + if let Some(b) = bbox(owner) { + t.add_box(b); + let (gx, gy) = figcraft_layout::grid_snap_lines_in(doc, owner, to * doc.world_transform(owner)); + for x in gx { + t.xs.push((x, b.y0, b.y1)); + } + for y in gy { + t.ys.push((y, b.x0, b.x1)); + } + } + // Everything in the frame. Nested layers count, so a guide over a group reaches its parts. + for id in doc.subtree(owner).into_iter().skip(1) { + if doc.node(id).is_some_and(|n| n.visible) + && let Some(b) = bbox(id) + { + t.add_box(b); + } + } + t + } + /// A box to snap to (in snap space): its edges and centre lines, and equal spacing with it. pub fn add_box(&mut self, r: Rect) { for x in [r.x0, r.center().x, r.x1] { @@ -317,6 +360,16 @@ impl Targets { (dx, dy, self.guides_spans(&xs, &ys)) } + /// The correction along one axis (x when `vertical`, else y) to the nearest target line within + /// `threshold`, in snap space. A guide moves on its own axis only, so the other is left alone. + pub fn snap_axis(&self, vertical: bool, offset: f64, threshold: f64) -> Option { + if !offset.is_finite() || !threshold.is_finite() || threshold < 0.0 { + return None; + } + let lines = if vertical { &self.xs } else { &self.ys }; + lines.iter().map(|(v, _, _)| v - offset).filter(|d| d.is_finite() && d.abs() <= threshold).min_by(|a, b| a.abs().total_cmp(&b.abs())) + } + /// Guide lines for edges of `r` that coincide with targets. pub fn guides_for(&self, r: Rect) -> Vec { let (xs, ys) = rect_spans(r); @@ -345,6 +398,14 @@ impl Targets { } } +/// Snap a guide on `owner` (a page or a top-level frame) to the nearest layer edge or centre along +/// its own axis, within `SNAP_PX` screen pixels at `zoom`. Returns the new offset in `owner`'s +/// coordinates, or `None` when nothing is in reach. +pub fn snap_guide(doc: &Document, page: NodeId, owner: NodeId, vertical: bool, offset: f64, zoom: f64) -> Option { + let threshold = SNAP_PX / zoom.max(1e-6); + Targets::gather_for_guide(doc, page, owner).snap_axis(vertical, offset, threshold).map(|d| offset + d).filter(|v| v.is_finite()) +} + /// The parent of the moving layers (the first one's), or the page. fn parent_of(doc: &Document, page: NodeId, moving: &[NodeId]) -> NodeId { moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent).unwrap_or(page) diff --git a/crates/engine/src/guide_snap_tests.rs b/crates/engine/src/guide_snap_tests.rs new file mode 100644 index 00000000..3402ef55 --- /dev/null +++ b/crates/engine/src/guide_snap_tests.rs @@ -0,0 +1,100 @@ +//! Guides snap to layer edges and centres when dragged out of a ruler or moved, on the guide's own +//! axis only, within five screen pixels at any zoom (page guides see the page's top-level layers; +//! frame guides see the frame's box, layout grids and every layer in it). + +use figcraft_doc::NodeId; +use figcraft_geom::Affine; +use serde_json::json; + +use crate::Session; +use crate::params::id_of; +use crate::snap::{Targets, snap_guide}; + +fn id(v: serde_json::Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +fn page_snap(s: &Session, vertical: bool, offset: f64, zoom: f64) -> Option { + let (doc, page) = (s.doc().unwrap(), s.page().unwrap()); + snap_guide(doc, page, page, vertical, offset, zoom) +} + +#[test] +fn a_page_guide_snaps_to_a_top_level_layers_edges_and_centre() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 300, "width": 50, "height": 40})).unwrap(); + // Edges at 100 and 150, centre at 125; within five pixels at zoom 1. + assert_eq!(page_snap(&s, true, 102.0, 1.0), Some(100.0)); + assert_eq!(page_snap(&s, true, 124.0, 1.0), Some(125.0)); + assert_eq!(page_snap(&s, true, 148.0, 1.0), Some(150.0)); + // Out of reach. + assert_eq!(page_snap(&s, true, 106.0, 1.0), None); + // The guide's own axis only: x 302 is far from the x edges, y 302 is a pixel above 300. + assert_eq!(page_snap(&s, true, 302.0, 1.0), None); + assert_eq!(page_snap(&s, false, 302.0, 1.0), Some(300.0)); +} + +#[test] +fn the_reach_is_five_screen_pixels_at_any_zoom() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 300, "width": 50, "height": 40})).unwrap(); + // Six page pixels away: out of reach at zoom 2 (2.5), in reach at zoom 0.5 (10). + assert_eq!(page_snap(&s, true, 106.0, 2.0), None); + assert_eq!(page_snap(&s, true, 106.0, 0.5), Some(100.0)); + // A zoom of zero is clamped rather than dividing by zero. + assert_eq!(page_snap(&s, true, 100.0, 0.0), Some(100.0)); +} + +#[test] +fn a_frame_guide_snaps_to_its_box_its_children_and_nested_layers() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 50, "width": 300, "height": 200})).unwrap()); + let inner = id(s.execute("node.create", &json!({"type": "frame", "parent": frame, "x": 10, "y": 10, "width": 60, "height": 60})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "parent": inner, "x": 20, "y": 20, "width": 20, "height": 20})).unwrap(); + let outside = id(s.execute("node.create", &json!({"type": "rectangle", "x": 1000, "y": 1000, "width": 20, "height": 20})).unwrap()); + let page = s.page().unwrap(); + let snap = |s: &Session, vertical: bool, offset: f64| snap_guide(s.doc().unwrap(), page, frame, vertical, offset, 1.0); + // The frame's own right edge is 300. + assert_eq!(snap(&s, true, 299.0), Some(300.0)); + // The nested rectangle sits at 30 in the frame; its left edge and centre (40) are targets. + assert_eq!(snap(&s, true, 31.0), Some(30.0)); + assert_eq!(snap(&s, true, 41.0), Some(40.0)); + // The layer outside the frame is not. + assert_eq!(snap(&s, true, 999.0), None); + // Hiding the nested rectangle takes its lines away. + let inner_rect = s.doc().unwrap().node(inner).unwrap().children[0]; + s.execute("node.set", &json!({"ids": [inner_rect], "props": {"visible": false}})).unwrap(); + assert_eq!(snap(&s, true, 31.0), None); + // A layer outside the frame still doesn't matter, and `outside` exists to prove the scope. + assert!(s.doc().unwrap().node(outside).is_some()); +} + +#[test] +fn a_frame_guide_snaps_along_the_frames_own_axes_when_it_is_turned() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 300, "height": 200})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 40, "y": 20, "width": 20, "height": 20})).unwrap(); + s.execute("node.set", &json!({"ids": [frame], "props": {"rotation": 30.0}})).unwrap(); + let (doc, page) = (s.doc().unwrap(), s.page().unwrap()); + let t = Targets::gather_for_guide(doc, page, frame); + assert_ne!(t.space, Affine::IDENTITY, "guides snap in the frame's space"); + assert!(t.xs.iter().any(|(x, _, _)| near(*x, 40.0)), "{:?}", t.xs); + // The rotation rounds the offsets in the last bits, so compare with a tolerance. + assert!(snap_guide(doc, page, frame, true, 41.0, 1.0).is_some_and(|v| near(v, 40.0))); + assert!(snap_guide(doc, page, frame, false, 21.0, 1.0).is_some_and(|v| near(v, 20.0))); +} + +#[test] +fn a_guide_with_nothing_near_stays_where_it_is() { + let s = Session::new(); + let page = s.page().unwrap(); + let doc = s.doc().unwrap(); + assert_eq!(snap_guide(doc, page, page, true, 12.5, 1.0), None); + assert_eq!(snap_guide(doc, page, page, false, 12.5, 1.0), None); + // Non-finite input is refused rather than spread. + assert_eq!(snap_guide(doc, page, page, true, f64::NAN, 1.0), None); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0db81fd1..d37a24e6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -295,6 +295,8 @@ mod gesture_preview_tests; #[cfg(test)] mod gradient_shorthand_tests; #[cfg(test)] +mod guide_snap_tests; +#[cfg(test)] mod hide_publishing_tests; #[cfg(test)] mod history_behaviour_tests; diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs index c3bb6589..909b761f 100644 --- a/crates/ui-egui/src/canvas/guides.rs +++ b/crates/ui-egui/src/canvas/guides.rs @@ -97,23 +97,28 @@ fn place(doc: &Document, owner: NodeId, index: usize, vertical: bool, offset: f6 Some(Placed { owner, index, vertical, offset, line: Some(guides::segment(doc, owner, vertical, offset)?) }) } -/// Guide `index` of `owner` (a page or frame) through page point `p`, its offset rounded to whole -/// pixels with `snap` (else to hundredths). -fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, snap: bool) -> Option { +/// Guide `index` of `owner` (a page or frame) through page point `p`. Its offset snaps to a nearby +/// layer edge or centre when `object_zoom` is set (`None`: object snapping off), then to whole +/// pixels with `pixel` (else to hundredths); a snapped offset keeps the edge exactly. +fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, pixel: bool, object_zoom: Option) -> Option { let ds = app.session.ds().ok()?; let v = guides::offset_at(&ds.doc, owner, vertical, p)?; - let offset = if snap { v.round() } else { (v * 100.0).round() / 100.0 }; + let offset = match object_zoom.and_then(|zoom| figcraft_engine::snap::snap_guide(&ds.doc, ds.page, owner, vertical, v, zoom)) { + Some(snapped) => snapped, + None if pixel => v.round(), + None => (v * 100.0).round() / 100.0, + }; if owner == ds.page { Some(Placed { owner, index, vertical, offset, line: None }) } else { place(&ds.doc, owner, index, vertical, offset) } } /// A guide dragged out of a ruler (`vertical`: the left one) and dropped at page point `p`: on the /// top-level frame under it, in the frame's coordinates and along its axis nearer the ruler's, or /// on the page. -fn drop_target(app: &FigApp, p: Point, vertical: bool, snap: bool) -> Option { +fn drop_target(app: &FigApp, p: Point, vertical: bool, pixel: bool, object_zoom: Option) -> Option { let ds = app.session.ds().ok()?; match guides::drop_owner(&ds.doc, ds.page, p) { - f if f != ds.page => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, snap), - page => through(app, page, usize::MAX, vertical, p, snap), + f if f != ds.page => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, pixel, object_zoom), + page => through(app, page, usize::MAX, vertical, p, pixel, object_zoom), } } @@ -182,18 +187,24 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { return false; } let r = app.canvas.rect; - let (pressed, released, alt) = ctx.input(|i| { - (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary), i.modifiers.alt) + let (pressed, released, alt, suspend) = ctx.input(|i| { + ( + i.pointer.button_pressed(egui::PointerButton::Primary), + i.pointer.button_released(egui::PointerButton::Primary), + i.modifiers.alt, + i.modifiers.command || i.modifiers.ctrl, + ) }); if let Some(drag) = ctx.data(|d| d.get_temp::(drag_id())) { let vertical = match drag { GuideDrag::New { vertical, .. } | GuideDrag::Move { vertical, .. } => vertical, }; let p = app.to_page(pos); - let snap = app.session.ds().map(|d| d.view.snap_pixel).unwrap_or(true); + let (pixel, object_zoom) = + app.session.ds().map(|d| (d.view.snap_pixel, (d.view.snap_objects && !suspend).then_some(d.view.zoom))).unwrap_or((true, None)); let at = match drag { - GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, snap), - GuideDrag::Move { owner, index, vertical, .. } => through(app, owner, index, vertical, p, snap), + GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, pixel, object_zoom), + GuideDrag::Move { owner, index, vertical, .. } => through(app, owner, index, vertical, p, pixel, object_zoom), }; // Alt: distances from the guide to the layers in the frame it crosses. let frame = drag.frame().or_else(|| at.map(|g| g.owner).filter(|o| app.session.ds().is_ok_and(|d| d.page != *o))); diff --git a/crates/ui-egui/src/guide_snap_tests.rs b/crates/ui-egui/src/guide_snap_tests.rs new file mode 100644 index 00000000..94278839 --- /dev/null +++ b/crates/ui-egui/src/guide_snap_tests.rs @@ -0,0 +1,97 @@ +//! Guides dragged out of a ruler snap to nearby layer edges and centres (Snap to Objects), on the +//! guide's own axis, and Cmd/Ctrl suspends that while the guides keep their pixel snapping. + +use egui::{Event, Modifiers, Pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rulers(mut s: Session) -> Ui { + s.execute("view.rulers", &json!({"on": true})).unwrap(); + Ui::new(s) +} + +fn guides(u: &Ui, owner: NodeId) -> Vec<(bool, f64)> { + let ds = u.app.session.ds().unwrap(); + ds.doc.node(owner).unwrap().guides().iter().map(|g| (g.vertical, g.offset)).collect() +} + +fn page(u: &Ui) -> NodeId { + u.app.session.ds().unwrap().page +} + +fn from_top_ruler(u: &Ui) -> Pos2 { + let r = u.app.canvas.rect; + Pos2::new(r.min.x + r.width() * 0.5, r.min.y + 5.0) +} + +fn from_left_ruler(u: &Ui) -> Pos2 { + let r = u.app.canvas.rect; + Pos2::new(r.min.x + 5.0, r.min.y + r.height() * 0.5) +} + +fn drag(u: &mut Ui, from: Pos2, to: Pos2) { + u.frame(vec![Event::PointerMoved(from)]); + u.button(from, true); + u.frame(vec![Event::PointerMoved(to)]); + u.button(to, false); +} + +fn drag_with(u: &mut Ui, from: Pos2, to: Pos2, m: Modifiers) { + u.frame_with(vec![Event::PointerMoved(from)], m); + u.frame_with(vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: m }], m); + u.frame_with(vec![Event::PointerMoved(to)], m); + u.frame_with(vec![Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: m }], m); +} + +#[test] +fn a_guide_dragged_from_a_ruler_snaps_to_a_layers_edge() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap(); + let mut u = rulers(s); + let start = from_top_ruler(&u); + let to = u.app.to_screen(200.0, 102.0); + drag(&mut u, start, to); + assert_eq!(guides(&u, page(&u)), vec![(false, 100.0)], "two pixels from the top edge"); +} + +#[test] +fn a_guide_dragged_over_a_frame_snaps_to_a_layer_inside_it() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 300, "height": 200})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + let mut u = rulers(s); + let start = from_left_ruler(&u); + let to = u.app.to_screen(112.0, 200.0); + drag(&mut u, start, to); + assert_eq!(guides(&u, frame), vec![(true, 10.0)], "two pixels from the child's left edge"); +} + +#[test] +fn with_snap_to_objects_off_the_guide_keeps_its_pixel_rounding() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap(); + s.execute("view.snapObjects", &json!({"on": false})).unwrap(); + let mut u = rulers(s); + let start = from_top_ruler(&u); + let to = u.app.to_screen(200.0, 102.0); + drag(&mut u, start, to); + assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)], "rounded to the pixel, not the edge"); +} + +#[test] +fn cmd_or_ctrl_suspends_guide_snapping() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap(); + let mut u = rulers(s); + let start = from_top_ruler(&u); + let to = u.app.to_screen(200.0, 102.0); + drag_with(&mut u, start, to, Modifiers::CTRL); + assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)]); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8d5d3e1b..9eed0438 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -90,6 +90,8 @@ mod gradient_shorthand_tests; #[cfg(test)] mod guide_release_tests; #[cfg(test)] +mod guide_snap_tests; +#[cfg(test)] mod home_recents_tests; #[cfg(test)] mod home_tests; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2604cfc9..217ad38f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (guides snap to layers when dragged; colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -63,7 +63,7 @@ interaction feel not yet compared. | Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) | | Scale tool (K): handles about an anchor, live factor, panel | done | | | Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children | -| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; thresholds not yet compared with Figma's | +| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; a guide dragged out of a ruler or moved snaps to layer edges and centres too (Snap to Objects; Cmd/Ctrl suspends), on its own axis only; page guides see the page's top-level layers, frame guides the frame's box, layout grids and every layer in it, along a rotated frame's own axes; thresholds not yet compared with Figma's (`guide_snap_tests`) | | Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing | | Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap; the arrows still nudge while measuring (Shift: the big step) and the distances follow; the selection's handles and size label hide while measuring (`measure_nudge_tests`, #894) | | Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) | @@ -183,6 +183,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | | 2026-10-18 | minor | Export: an "XML" format (Android Vector Drawable, said on hover) in the Export section's format menu, with "Include id attribute" and a compatibility choice (best effort, exact only, ignore unsupported) in its settings, and an XML preset in File ▸ Export…; the export toast says when content was changed or left out. Numbers unchanged | +| 2026-10-29 | minor | Guides snap to layers when dragged: a guide pulled from a ruler or moved snaps to nearby layer edges and centres (Snap to Objects; Cmd/Ctrl suspends), on its own axis only, within five screen pixels at any zoom; page guides see the page's top-level layers, frame guides the frame's box, layout grids and every layer in it, along a rotated frame's own axes; a snapped guide keeps the edge exactly while an unsnapped one still rounds to the pixel (`guide_snap_tests`). Summary and hours unchanged | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME |