diff --git a/crates/engine/src/canvas/rotation_origin.rs b/crates/engine/src/canvas/rotation_origin.rs index 8498f708a..5552bc77b 100644 --- a/crates/engine/src/canvas/rotation_origin.rs +++ b/crates/engine/src/canvas/rotation_origin.rs @@ -8,7 +8,7 @@ use figcraft_doc::{Document, NodeId}; use figcraft_geom::Point; use crate::cmd::edit::roots; -use crate::tools::{SNAP_PX, selection_box}; +use crate::tools::{UNMEASURED_SNAP_PX, selection_box}; use crate::{DocState, EngineError, Result, Session}; /// How near (screen pixels) a press must be to grab the origin handle. @@ -115,7 +115,7 @@ impl Session { let snapped = box_points(&ds.doc, &ds.selection).and_then(|b| { b.iter() .enumerate() - .filter(|(_, q)| (**q - p).hypot() * zoom.max(1e-6) <= SNAP_PX) + .filter(|(_, q)| (**q - p).hypot() * zoom.max(1e-6) <= UNMEASURED_SNAP_PX) .min_by(|x, y| (*x.1 - p).hypot().total_cmp(&(*y.1 - p).hypot())) .map(|(i, q)| (i, *q)) }); diff --git a/crates/engine/src/canvas/snap.rs b/crates/engine/src/canvas/snap.rs index cd40437d3..ff1a6b64d 100644 --- a/crates/engine/src/canvas/snap.rs +++ b/crates/engine/src/canvas/snap.rs @@ -9,7 +9,7 @@ use figcraft_geom::{Affine, Point, Rect, Vec2, transform_rect_bbox}; use serde::Serialize; use crate::guides; -use crate::tools::SNAP_PX; +use crate::tools::UNMEASURED_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. @@ -438,14 +438,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 +/// its own axis, within `UNMEASURED_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 { // The guide moves along the owner's own axis (x for a vertical guide, y for a horizontal one), // which the owner's transform may scale: convert the screen-pixel reach into owner units. let [a, b, c, d, _, _] = doc.world_transform(owner).as_coeffs(); let axis = if vertical { a.hypot(b) } else { c.hypot(d) }; - let threshold = SNAP_PX / (zoom.max(1e-6) * axis.max(1e-6)); + let threshold = UNMEASURED_SNAP_PX / (zoom.max(1e-6) * axis.max(1e-6)); Targets::gather_for_guide(doc, page, owner).snap_axis(vertical, offset, threshold).map(|d| offset + d).filter(|v| v.is_finite()) } diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index a3284cd52..0c409a682 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -34,7 +34,7 @@ pub use handles::{ PENCIL_STROKE, SMALL_BOX_PX, anchor_name, anchor_of, box_sides_px, handle_at, handle_at_corners, label_at, locked_in, scale_anchor_point, selection_box, selection_frame, }; -pub(crate) use handles::{SNAP_PX, handle_cursor, readable_text_fills, resized_box}; +pub(crate) use handles::{SNAP_PX, UNMEASURED_SNAP_PX, handle_cursor, readable_text_fills, resized_box}; #[cfg(test)] pub(crate) use pen::pressure_profile; diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs index 9fa04d9bb..f60866ec1 100644 --- a/crates/engine/src/canvas/tools/handles.rs +++ b/crates/engine/src/canvas/tools/handles.rs @@ -71,8 +71,13 @@ const HANDLE_PX: f64 = 5.0; const ROTATE_PX: f64 = 18.0; -/// Snap distance in screen pixels. -pub(crate) const SNAP_PX: f64 = 5.0; +/// Object snap distance in screen pixels: measured in Figma, where an edge snaps at a gap of 4.4 +/// screen px and not at 4.6, at 100% and at 200% zoom. +pub(crate) const SNAP_PX: f64 = 4.5; + +/// Snap distance in screen pixels for ruler guides and the rotation origin, not measured against +/// Figma. +pub(crate) const UNMEASURED_SNAP_PX: f64 = 5.0; /// Screen pixels the pointer travels after a press before a move or resize starts: a click or a /// double-click made with an unsteady hand doesn't drag what it lands on. diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index e22eb0418..56d581b32 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -625,6 +625,27 @@ fn smart_guides_snap_to_edges_centres_and_equal_spacing() { assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0)); } +#[test] +fn a_layer_edge_snaps_within_4_5_screen_pixels_at_any_zoom() { + // Measured in Figma (2026-11-08): a dragged edge snaps at a gap of 4.4 screen px and not at 4.6, + // at 100% and at 200% zoom. + for (zoom, gap_px, snaps) in [(1.0, 4.4, true), (1.0, 4.8, false), (2.0, 4.4, true), (2.0, 4.8, false)] { + let mut s = Session::new(); + ex(&mut s, "view.snapPixel", json!({"on": false})); + rect(&mut s, 0.0, 0.0, 100.0, 100.0, None); + let b = rect(&mut s, 300.0, 300.0, 100.0, 100.0, None); + ex(&mut s, "select.set", json!({"ids": [b]})); + let gap = gap_px / zoom; + let to = Point::new(350.0 - (200.0 - gap), 350.0); + for (kind, p) in [(PointerKind::Down, Point::new(350.0, 350.0)), (PointerKind::Move, to), (PointerKind::Up, to)] { + s.pointer(&PointerEvent::at(kind, p.x, p.y, zoom)).unwrap(); + } + let want = if snaps { 100.0 } else { 100.0 + gap }; + let got = bounds(&s, b).x0; + assert!(near(got, want), "zoom {zoom}, gap {gap_px} screen px: left edge at {got}, wanted {want}"); + } +} + #[test] fn snapping_to_layers_wins_over_the_pixel_grid() { // Both on (the defaults): a layer's centre at x = 5.5 is still a target. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index aa0ef25bd..b19d0c5a5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -69,7 +69,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`; 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`) | +| 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; the object snap threshold is 4.5 screen pixels, measured against Figma (snapped at a gap of 4.4 screen px, not at 4.6, at 100% and 200% zoom, 2026-11-08; the equal-spacing snap shares it), but the ruler-guide and rotation-origin thresholds (5 px), the red guide's look and snapping with Snap to pixel grid off are not yet compared with Figma's (`guide_snap_tests`, `transform_behaviour_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`) | @@ -187,6 +187,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-08 | patch | Smart guides: the object snap threshold is 4.5 screen pixels, measured against Figma (a dragged edge snapped at a gap of 4.4 screen px and not at 4.6, at 100% and at 200% zoom, Snap to pixel grid on); FigCraft used 5. Equal spacing shares the move's threshold so it follows; ruler-guide and rotation-origin snapping keep 5, unmeasured (`transform_behaviour_tests`) | | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |