From 47fff7465545cb4ef58fa6196b8694564004b799 Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 12 Oct 2026 16:52:37 +0700 Subject: [PATCH] M1: double-clicks into text in groups, and a drag threshold (#160) A move or a resize starts only once the pointer has travelled 3 px on screen, and a double-click drops the gesture its second press began, so double-clicking text inside a group reliably selects it and then edits it instead of sometimes dragging or resizing it. --- crates/engine/src/double_click_tests.rs | 107 ++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/tools.rs | 22 ++++- docs/ui-parity.md | 4 +- 4 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/double_click_tests.rs diff --git a/crates/engine/src/double_click_tests.rs b/crates/engine/src/double_click_tests.rs new file mode 100644 index 00000000..e51cb7da --- /dev/null +++ b/crates/engine/src/double_click_tests.rs @@ -0,0 +1,107 @@ +//! Double-clicks as a mouse sends them (press, release, press, double-click, with a pixel or two +//! of hand movement between) into a group's text, and the drag threshold that keeps such clicks +//! from moving or resizing what they land on. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::NodeId; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// A frame holding a group of a rectangle and a text layer at (60, 60); returns (group, text). +fn scene(s: &mut Session) -> (NodeId, NodeId) { + let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}))["id"]).unwrap(); + let r = id_of(&ex(s, "shape.rectangle", json!({"x": 40, "y": 40, "width": 200, "height": 60, "parent": f}))["id"]).unwrap(); + let t = id_of(&ex(s, "text.create", json!({"text": "Hi there", "x": 60, "y": 60, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap(); + let g = id_of(&ex(s, "object.group", json!({"ids": [r, t]}))["id"]).unwrap(); + ex(s, "select.none", json!({})); + (g, t) +} + +fn send(s: &mut Session, kind: PointerKind, x: f64, y: f64, zoom: f64) { + s.pointer(&PointerEvent::at(kind, x, y, zoom)).unwrap(); +} + +/// A click at (x, y) whose release is `jitter` away. +fn click(s: &mut Session, x: f64, y: f64, jitter: f64, zoom: f64) { + send(s, PointerKind::Down, x, y, zoom); + send(s, PointerKind::Move, x + jitter, y, zoom); + send(s, PointerKind::Up, x + jitter, y, zoom); +} + +/// What the canvas sends for a double-click: a click, a second press, the double-click (no +/// release), then the pointer hovering on. +fn double_click(s: &mut Session, x: f64, y: f64, jitter: f64, zoom: f64) { + click(s, x, y, jitter, zoom); + send(s, PointerKind::Down, x, y, zoom); + send(s, PointerKind::Move, x + jitter, y, zoom); + send(s, PointerKind::DoubleClick, x + jitter, y, zoom); + send(s, PointerKind::Move, x + jitter + 1.0, y, zoom); +} + +fn editing(s: &Session) -> Option { + s.ds().unwrap().text_edit.as_ref().map(|t| t.id) +} + +fn origin(s: &Session, id: NodeId) -> (f64, f64, f64, f64) { + let b = s.doc().unwrap().world_bounds(id).unwrap(); + (b.x0, b.y0, b.width(), b.height()) +} + +#[test] +fn double_clicks_reach_and_edit_text_in_a_group_wherever_they_land() { + // The middle of the text (a move would start there), near its edges (a resize would) and + // with the hand moving a pixel or two between press and release. + for (x, y) in [(100.0, 70.0), (100.0, 61.0), (100.0, 78.0), (61.0, 70.0), (80.0, 70.0)] { + for jitter in [0.0, 1.0, 2.0] { + let mut s = Session::new(); + let (g, t) = scene(&mut s); + let before = origin(&s, t); + click(&mut s, x, y, jitter, 1.0); + assert_eq!(s.selection(), vec![g], "a click picks the group ({x}, {y}, {jitter})"); + double_click(&mut s, x, y, jitter, 1.0); + assert_eq!(s.selection(), vec![t], "a double-click picks the text ({x}, {y}, {jitter})"); + assert_eq!(editing(&s), None); + double_click(&mut s, x, y, jitter, 1.0); + assert_eq!(editing(&s), Some(t), "the next one edits it ({x}, {y}, {jitter})"); + assert_eq!(origin(&s, t), before, "and nothing was dragged ({x}, {y}, {jitter})"); + } + } +} + +#[test] +fn a_press_drags_only_past_the_threshold_in_screen_pixels() { + for (zoom, by, moves) in [(1.0, 2.0, false), (1.0, 4.0, true), (4.0, 0.5, false), (4.0, 1.0, true), (0.25, 8.0, false), (0.25, 16.0, true)] { + let mut s = Session::new(); + let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}))["id"]).unwrap(); + ex(&mut s, "view.snapPixel", json!({"on": false})); + // A move from the middle. + send(&mut s, PointerKind::Down, 100.0, 100.0, zoom); + send(&mut s, PointerKind::Move, 100.0 + by, 100.0, zoom); + send(&mut s, PointerKind::Up, 100.0 + by, 100.0, zoom); + assert_eq!(origin(&s, r).0 != 0.0, moves, "move by {by} at {zoom}"); + // Past the threshold the layer follows the pointer from where it was pressed, and coming + // back near the start keeps following. + send(&mut s, PointerKind::Down, 100.0, 100.0, zoom); + send(&mut s, PointerKind::Move, 100.0 + 40.0 / zoom, 100.0, zoom); + send(&mut s, PointerKind::Move, 100.0 + 1.0 / zoom, 100.0, zoom); + let x = origin(&s, r).0; + send(&mut s, PointerKind::Up, 100.0 + 1.0 / zoom, 100.0, zoom); + s.undo().unwrap(); + assert!(x != 0.0 || !moves, "back near the start still follows at {zoom}"); + } + // A resize from the right edge. + for (by, resizes) in [(2.0, false), (4.0, true)] { + let mut s = Session::new(); + let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}))["id"]).unwrap(); + send(&mut s, PointerKind::Down, 200.0, 100.0, 1.0); + send(&mut s, PointerKind::Move, 200.0 + by, 100.0, 1.0); + send(&mut s, PointerKind::Up, 200.0 + by, 100.0, 1.0); + assert_eq!(origin(&s, r).2 != 200.0, resizes, "resize by {by}"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0364ac87..b69fff3d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1308,6 +1308,8 @@ mod devfocus_tests; #[cfg(test)] mod devinspect_tests; #[cfg(test)] +mod double_click_tests; +#[cfg(test)] mod draw_tools_tests; #[cfg(test)] mod effects_tests; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cb647cf9..14a6661c 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -174,6 +174,11 @@ pub struct PointerEvent { pub pressure: Option, } +/// The pointer is still within [`DRAG_SLOP_PX`] of where it was pressed. +fn within_slop(ev: &PointerEvent, start: Point) -> bool { + (ev.p() - start).hypot() * ev.zoom.max(1e-6) < DRAG_SLOP_PX +} + impl PointerEvent { pub fn at(kind: PointerKind, x: f64, y: f64, zoom: f64) -> Self { Self { kind, x, y, sx: x * zoom, sy: y * zoom, shift: false, alt: false, cmd: false, ctrl: false, zoom, pressure: None } @@ -300,6 +305,7 @@ pub(crate) enum Gesture { start: Point, r: Box, scale_tool: bool, + moved: bool, }, Rotate { center: Point, @@ -498,6 +504,9 @@ 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; +/// 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. +const DRAG_SLOP_PX: f64 = 3.0; /// Corners of the selection's handle box in page coordinates (tl, tr, br, bl). pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> { @@ -1018,6 +1027,7 @@ impl Session { start: ev.p(), r: Box::new(ResizeStart { nodes, frame, single, handle: h }), scale_tool: self.tool.tool == ToolId::Scale, + moved: false, }; return Ok(()); } @@ -1091,6 +1101,7 @@ impl Session { self.set_selection(sel); Ok(()) } + Gesture::Move { start, moved: false, .. } | Gesture::Resize { start, moved: false, .. } if within_slop(ev, *start) => Ok(()), Gesture::Move { start, ids, targets, duplicate, .. } => { let mut d = ev.p() - *start; if ev.shift { @@ -1208,7 +1219,11 @@ impl Session { } return Ok(()); } - Gesture::Resize { start, r, scale_tool } => self.resize_drag(ev, *start, r, *scale_tool), + Gesture::Resize { start, r, scale_tool, .. } => { + let res = self.resize_drag(ev, *start, r, *scale_tool); + self.tool.gesture = Gesture::Resize { start: *start, r: r.clone(), scale_tool: *scale_tool, moved: true }; + return res; + } Gesture::Rotate { center, start_angle, ids } => { let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees(); if ev.shift { @@ -1680,6 +1695,11 @@ impl Session { self.widget_click(ev.p(), ev.tol() * 2.0)?; return Ok(()); } + // The second press began a move or a resize: a double-click isn't one. + if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. }) { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + } let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; let target = select_target(&doc, leaf, &sel, true); // Text: edit at the click. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c2636087..ed351784 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-12 · **Change:** minor (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-12 · **Change:** minor (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: @@ -55,6 +55,7 @@ interaction feel not yet compared. | Rotation origin (Alt+R) | done | a handle at the selection's centre to drag (snaps to the box's corners, edge middles and centre), double-click puts it back, Escape or Alt+R hides it; the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about it; it follows the layers and lasts as long as the selection, not saved (the documentation doesn't say; pinned) | | Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. No crosshair to move with the arrows first | | Alt-drag duplicate | done | | +| 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 (`double_click_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 | @@ -162,6 +163,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Drag threshold and double-clicks: the second press of a double-click began a move or resize that was never finished, so double-clicking text inside a group often didn't start editing (from the middle of the text) or dragged and resized it when the hand moved a pixel or two; a move or resize now starts only after 3 px of travel on screen and a double-click drops the press's gesture first | | 2026-10-12 | minor | Rotation origin and placing with Enter: Alt+R shows a rotation origin handle to drag (snapping to the box's corners, edge middles and centre; double-click resets; Escape hides; kept while the selection lasts, not saved), which the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about (`transform.rotationOrigin`, `transform.setRotationOrigin`); Enter with a shape or frame tool chosen, on the canvas or the toolbar's tool, places a click-sized one in the middle of the view (`tool.place`). Documented shortcuts: 128 bound, 0 missing. The keyboard box selection's toast and screen-reader text name Cmd/Ctrl+arrows too; the shortcuts panel cuts a label that doesn't fit before its keycaps with "…" (measured in the font it's drawn in; long labels ran into the keycaps) and shows it in full on hover | | 2026-10-12 | minor | Keyboard shortcut parity suite and bindings: a table of the 129 documented shortcuts per platform (126 bound, 1 moved for a documented conflict, 2 missing, 2 kept by a platform) plus 48 pinned undocumented bindings; 16 rows newly bound (see Keyboard shortcuts); Ctrl to suspend snapping on macOS done; keyboard box selection on Alt+Space / Ctrl+Space | | 2026-10-12 | minor | Multiplayer cursors, cursor chat and spotlight: missing → partial (live sessions: chat bubbles at the pointer that fade, `/` to chat, spotlight from your avatar's menu with a banner to stop or follow again) |