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.
This commit is contained in:
BFlat
2026-10-12 16:52:37 +07:00
committed by GitHub
parent 1610d3db86
commit 47fff74655
4 changed files with 133 additions and 2 deletions
+107
View File
@@ -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<NodeId> {
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}");
}
}
+2
View File
@@ -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;
+21 -1
View File
@@ -174,6 +174,11 @@ pub struct PointerEvent {
pub pressure: Option<f32>,
}
/// 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<ResizeStart>,
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.
+3 -1
View File
@@ -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) |