mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 22:02:38 +00:00
Merge pull request #2310 from TrevorBrown-dev/fix/layers-icon-double-click-zooms
M8: a double-click on a layer's icon zooms to it, as in Figma
This commit is contained in:
3 files changed
+117
-5
No files matched your search
@@ -841,3 +841,100 @@ fn search_fields_keep_their_text_in_the_middle() {
|
||||
let off = off_centre(&mut u, "Search assets");
|
||||
assert!(off < 1.0, "Search assets is {off} points off the middle");
|
||||
}
|
||||
|
||||
/// The centre of `id`'s icon, a top-level row: the row's left edge is the first point from the
|
||||
/// left at which the pointer is over it, and the icon's centre is 8 (margin) + 4 + 22 points
|
||||
/// further right.
|
||||
fn icon_of(u: &mut Ui, id: NodeId) -> Pos2 {
|
||||
let y = u.row_at(id).y;
|
||||
let mut left = None;
|
||||
for x in 0..150 {
|
||||
let p = Pos2::new(x as f32, y);
|
||||
u.app.session.ds_mut().unwrap().hover = None;
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
if u.app.session.ds().unwrap().hover == Some(id) {
|
||||
left = Some(x as f32);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Pos2::new(left.expect("the row has a left edge") + 34.0, y)
|
||||
}
|
||||
|
||||
/// Measured in Figma's web app, 2026-11-12: a double-click on a layer's icon zooms the canvas to
|
||||
/// the layer and starts no rename (a double-click on the name renames, as before).
|
||||
#[test]
|
||||
fn a_double_click_on_a_layers_icon_zooms_to_it_instead_of_renaming() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "near", 0.0, None);
|
||||
let far = rect(&mut s, "far", 3000.0, None);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.frame(vec![]);
|
||||
let icon = icon_of(&mut u, far);
|
||||
let view = |u: &Ui| {
|
||||
let v = &u.app.session.ds().unwrap().view;
|
||||
(v.cx, v.cy, v.zoom)
|
||||
};
|
||||
let before = view(&u);
|
||||
for _ in 0..2 {
|
||||
u.button(icon, true);
|
||||
u.button(icon, false);
|
||||
}
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.ui.rename, None, "a double-click on the icon renames nothing");
|
||||
assert_eq!(u.app.session.selection(), vec![far], "the layer is selected");
|
||||
let after = view(&u);
|
||||
assert_ne!(after, before, "the view moved");
|
||||
u.app.session.execute("view.set", &json!({"cx": before.0, "cy": before.1, "zoom": before.2})).unwrap();
|
||||
u.app.session.execute("view.zoomSelection", &json!({})).unwrap();
|
||||
assert_eq!(after, view(&u), "the view frames the layer, as view.zoomSelection does");
|
||||
}
|
||||
|
||||
/// A double-click's release is also a click on the row; with Ctrl held it must not toggle the layer
|
||||
/// back out of the selection the zoom just made, nor can Shift restore a range.
|
||||
#[test]
|
||||
fn an_icon_double_click_keeps_its_selection_when_a_modifier_is_held() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "near", 0.0, None);
|
||||
let far = rect(&mut s, "far", 3000.0, None);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.frame(vec![]);
|
||||
let icon = icon_of(&mut u, far);
|
||||
let before = (u.app.session.ds().unwrap().view.cx, u.app.session.ds().unwrap().view.zoom);
|
||||
for _ in 0..2 {
|
||||
for pressed in [true, false] {
|
||||
let e = Event::PointerButton { pos: icon, button: egui::PointerButton::Primary, pressed, modifiers: CMD };
|
||||
u.frame_with(vec![Event::PointerMoved(icon), e], CMD);
|
||||
}
|
||||
}
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.session.selection(), vec![far], "the layer stays selected");
|
||||
assert_eq!(u.app.ui.rename, None);
|
||||
let v = &u.app.session.ds().unwrap().view;
|
||||
assert_ne!((v.cx, v.zoom), before, "the view frames the layer");
|
||||
}
|
||||
|
||||
/// A Shift-double-click on an icon leaves just that layer selected, and it becomes the anchor: the
|
||||
/// next Shift-click selects the range from it, not a toggle.
|
||||
#[test]
|
||||
fn an_icon_double_click_becomes_the_range_anchor() {
|
||||
let (mut u, [a, b, c, d]) = four();
|
||||
u.frame(vec![]);
|
||||
let a_row = u.row_at(a);
|
||||
click(&mut u, a_row, Modifiers::NONE);
|
||||
let icon = icon_of(&mut u, b);
|
||||
for _ in 0..2 {
|
||||
for pressed in [true, false] {
|
||||
let e = Event::PointerButton { pos: icon, button: egui::PointerButton::Primary, pressed, modifiers: SHIFT };
|
||||
u.frame_with(vec![Event::PointerMoved(icon), e], SHIFT);
|
||||
}
|
||||
}
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert_eq!(u.app.session.selection(), vec![b], "the icon's layer is all that is selected");
|
||||
let d_row = u.row_at(d);
|
||||
click(&mut u, d_row, SHIFT);
|
||||
assert_eq!(sorted(u.app.session.selection()), sorted(vec![b, c, d]), "the range from the icon's layer");
|
||||
}
|
||||
@@ -1241,7 +1241,8 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
let icon = node_icon(&doc, row.id);
|
||||
let shown = n.visible && !row.dimmed;
|
||||
let icon_col = row_color(&t, is_comp || icon == Icon::Slot, shown, t.icon, t.text_tertiary);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(x0 + 22.0, r.center().y), vec2(14.0, 14.0)), icon, icon_col);
|
||||
let icon_rect = Rect::from_center_size(pos2(x0 + 22.0, r.center().y), vec2(14.0, 14.0));
|
||||
icons::paint(ui.painter(), icon_rect, icon, icon_col);
|
||||
let name_x = x0 + 36.0;
|
||||
if app.ui.rename.is_some_and(|rn| rn.id == row.id) {
|
||||
if let Some(new) = rename_field(app, ui, row.id, row.depth, &n.name, r) {
|
||||
@@ -1262,8 +1263,21 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
if resp.clicked() {
|
||||
clicked = Some((row.id, ui.input(|i| i.modifiers)));
|
||||
}
|
||||
if resp.double_clicked() && !crate::menus::dev_mode_refuses(app, "object.rename") {
|
||||
app.ui.rename = Some(Rename { id: row.id, start: true });
|
||||
if resp.double_clicked() {
|
||||
// On the icon it zooms to the layer and renames nothing (measured in Figma's web
|
||||
// app, 2026-11-12); elsewhere on the row it renames.
|
||||
if resp.interact_pointer_pos().is_some_and(|p| icon_rect.contains(p)) {
|
||||
if sel.as_slice() != [row.id] {
|
||||
app.run("select.set", json!({"ids": [row.id]}));
|
||||
}
|
||||
app.run("view.zoomSelection", json!({}));
|
||||
// The release is also a click: it must not toggle or extend the selection.
|
||||
clicked = None;
|
||||
// The layer is the anchor of the next Shift-click's range.
|
||||
app.ui.layer_anchor = Some(row.id);
|
||||
} else if !crate::menus::dev_mode_refuses(app, "object.rename") {
|
||||
app.ui.rename = Some(Rename { id: row.id, start: true });
|
||||
}
|
||||
}
|
||||
if resp.secondary_clicked() {
|
||||
if !sel.contains(&row.id) {
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12, rounds the gap half up, tidies overlapping rows and packs a grid along its longer axis, and is greyed out for fewer than three layers, a diagonal and an irregular layout (M8); after Crop is chosen in the image fill's colour picker, a drag on the image crops it instead of moving the layer, and no internal gesture message shows (#2307); Align is disabled for a layer in an auto layout frame's flow, as measured in Figma's web app on 2026-11-12, with a tip saying the frame places it; Select layer lists locked layers with a padlock and selects them, and shows only with more than one layer under the pointer (#1560); M8: Scale tool handle drags pivot on the opposite handle whatever the origin, and the origin starts at the centre (measured in Figma's web app, 2026-11-09); M8: a selected polygon or star has a Count handle on the canvas, on its first point clockwise from the top; Zoom to selection leaves a 40 pt margin and clears the bottom toolbar, measured in Figma's web app; M1: Paste over selection puts the pasted layers just above the selected layer; drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis, Alt copies, Shift keeps a resize's ratio, Alt keeps its centre, all read afresh on every move, and FigCraft matches (Ctrl/Cmd not measurable); a mirror mode (angle, or angle and length) on a sharp point turns it smooth, as in Figma (#2110); M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M8: a double-click on a layer's icon zooms to it instead of renaming, measured in Figma's web app on 2026-11-12; Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12, rounds the gap half up, tidies overlapping rows and packs a grid along its longer axis, and is greyed out for fewer than three layers, a diagonal and an irregular layout (M8); after Crop is chosen in the image fill's colour picker, a drag on the image crops it instead of moving the layer, and no internal gesture message shows (#2307); Align is disabled for a layer in an auto layout frame's flow, as measured in Figma's web app on 2026-11-12, with a tip saying the frame places it; Select layer lists locked layers with a padlock and selects them, and shows only with more than one layer under the pointer (#1560); M8: Scale tool handle drags pivot on the opposite handle whatever the origin, and the origin starts at the centre (measured in Figma's web app, 2026-11-09); M8: a selected polygon or star has a Count handle on the canvas, on its first point clockwise from the top; Zoom to selection leaves a 40 pt margin and clears the bottom toolbar, measured in Figma's web app; M1: Paste over selection puts the pasted layers just above the selected layer; drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis, Alt copies, Shift keeps a resize's ratio, Alt keeps its centre, all read afresh on every move, and FigCraft matches (Ctrl/Cmd not measurable); a mirror mode (angle, or angle and length) on a sharp point turns it smooth, as in Figma (#2110); M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a fielLine truncated
|
||||
|
||||
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:
|
||||
@@ -49,7 +49,7 @@ interaction feel only partly compared: the interactions measured so far, case by
|
||||
| Expand and collapse: Alt-click opens or closes everything inside; Collapse layers (Alt+L) keeps the way to the selection open | done | Alt-click pinned (the documentation is silent) |
|
||||
| Drag to reorder and into frames, groups and auto layout: insertion line, spring-loaded open, several layers together, Escape cancels | done | several layers dropped together land together (they split apart before); instances and a layer's own children refuse drops |
|
||||
| Eye and padlock: click, or drag across rows to set several in one step; layers in a locked frame show a faint padlock | done | the toggles no longer vanish under the pointer (clicks on them could miss) (a child's padlock can't be toggled while its parent is locked) |
|
||||
| Rename: double click, F2 / Cmd+R, Tab to the next row; several layers open Rename layers (match, regular expressions, current name and number codes, start value, preview) | done | numbered in stacking order, pinned; Tab on the last row wraps to the first row's rename, measured in Figma's web app on 2026-11-12 (`tab_on_the_last_row_commits_and_wraps_to_the_first`); Shift+Tab on the first row ends the rename (not measured); Tab and Shift+Tab move only the rename, the selection stays on the layer first renamed, measured in Figma's web app on 2026-11-12 (`tab_while_renaming_leaves_the_selection_on_the_first_layer`) |
|
||||
| Rename: double click, F2 / Cmd+R, Tab to the next row; several layers open Rename layers (match, regular expressions, current name and number codes, start value, preview) | done | numbered in stacking order, pinned; Tab on the last row wraps to the first row's rename, measured in Figma's web app on 2026-11-12 (`tab_on_the_last_row_commits_and_wraps_to_the_first`); Shift+Tab on the first row ends the rename (not measured); Tab and Shift+Tab move only the rename, the selection stays on the layer first renamed, measured in Figma's web app on 2026-11-12 (`tab_while_renaming_leaves_the_selection_on_the_first_layer`); a double-click on a row's icon zooms to the layer (selecting it first) and renames nothing, while a double-click on the name or elsewhere on the row still renames, measured in Figma's web app on 2026-11-12 (`a_double_click_on_a_layers_icon_zooms_to_it_instead_of_renaming`) |
|
||||
| Find layers (filter by name) | done | not covered by the documentation |
|
||||
| Pages: add, rename, drag to reorder, duplicate ("Copy of"), delete, dividers (a blank page named with a hyphen) | done | the line between Pages and Layers drags to size the pages list, kept in the preferences; a long list scrolls in its own area and a page dropped outside the visible list stays put (#272) (`layers_behaviour_tests`) |
|
||||
| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | Shift+I (Quick insert), Alt+2 and View ▸ Panels ▸ Assets open it; the toolbar's Resources button was removed (the left rail's Assets tab and those shortcuts are the ways in) |
|
||||
@@ -216,6 +216,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | patch | M8: a double-click on a layer's icon in the Layers panel selects the layer and zooms the canvas to it (`view.zoomSelection`) and starts no rename, as measured in Figma's web app on 2026-11-12; a double-click on the name or elsewhere on the row still renames; the icon's hit area is the rect the row paints the icon in (`a_double_click_on_a_layers_icon_zooms_to_it_instead_of_renaming`), Rename row |
|
||||
| 2026-11-12 | minor | M8: Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12. A row or column evens one gap (the most common one, the smaller of a tie, else the mean rounded half up) and keeps the other axis, layers of a row may overlap along X (also in a chain, and a negative gap is raised so no layer reaches the one two before it, measured in Figma's web app on 2026-11-13), and a grid is packed along the axis with more groups; fewer than three layers, layers that all overlap, a diagonal and an irregular layout grey the row out and move nothing. Measured: those rules and the unavailable cases; FigCraft's choices: a 2 x 2 grid as columns, auto layout flow children, the 2,000 layer cap, rotated layers by their box, `spacing` with two layers, and no rounding with pixel snapping off. Tests: tidy_tests (a_row_keeps_its_order_sizes_and_y_and_evens_the_gaps, gaps_that_all_differ_take_their_mean_taken_down, a_mean_is_rounded_half_up, two_equally_common_gaps_give_the_smaller, an_overlapping_pair_in_a_row_is_tidied_and_its_negative_gap_counts, a_grid_is_tidied_by_columns, a_transposed_grid_is_tidied_by_rows, layers_on_a_diagonal_are_unavailable, an_irregular_selection_is_unavailable and the rest). |
|
||||
| 2026-11-13 | patch | M8, #2307: with Crop chosen in an image fill's colour picker, a press and drag on the image moved the whole layer: the press closed the picker, which committed the crop as a plain move. The picker now treats a crop drag as its own control (as it does the open gradient's handles, #1852), so the drag moves the picture inside the layer. A drag whose gesture something else committed under the held button no longer shows the engine's internal "no gesture in progress" as a message (`EngineError::NoGesture`, dropped by the canvas). Tests: `crop_image_tests::crop_from_the_colour_picker_then_a_drag_on_the_image_moves_the_crop_not_the_layer`, `a_drag_whose_gesture_was_committed_under_the_button_shows_no_message`. |
|
||||
| 2026-11-12 | minor | M8: Align is unavailable for the one selected layer of an auto layout frame that takes part in its flow, as in Figma (measured in Figma's web app on 2026-11-12: the Design panel's Align buttons are greyed out, Alt+A and Alt+S do nothing and the frame's alignment doesn't change). `align_target` returns none for it, so `can_align` is false and the `align.*` commands leave the layout alone; the disabled tip reads "A layer in an auto layout frame is placed by its frame" (`align_blocked_by_auto_layout`). An absolutely positioned child still aligns to its frame and multi-layer selections are unchanged (neither measured). Single and multi-layer Align and Distribute otherwise matched Figma; Tidy up differs (Figma keeps the order and sizes and evens one gap per axis) and is not yet changed. Tests: `align_is_unavailable_for_a_child_in_an_auto_layout_flow`, `an_absolute_child_of_an_auto_layout_frame_still_aligns_to_it`, `the_align_buttons_are_off_for_a_layer_in_an_auto_layout_flow` |
|
||||
|
||||
Reference in new issue
Block a user