mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user