Merge main into #2319

This commit is contained in:
bflatastic committed 2026-11-14 01:14:26 +07:00
commit cf0ebbe27e
7 files changed
+71 -4

No files matched your search

+4
View File
@@ -14,6 +14,10 @@ pub enum EngineError {
NoNode(NodeId),
#[error("{0}")]
Other(String),
/// A preview was asked for with no gesture open (something committed it under a held button).
/// Not for the user: the canvas drops it, as the drag it belonged to is over.
#[error("no gesture in progress")]
NoGesture,
/// A command run in the background sent a request to a server and runs again with the answer
/// ([`background`]); never returned to other callers.
#[error("waiting for the server")]
+1 -1
View File
@@ -65,7 +65,7 @@ impl Session {
/// Replace the document with `f(snapshot)` for a live preview during a gesture.
pub fn preview(&mut self, f: impl FnOnce(&mut Document, &mut Vec<NodeId>) -> Result<()>) -> Result<()> {
let ds = self.ds_mut()?;
let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) };
let Some(it) = ds.interaction.clone() else { return Err(EngineError::NoGesture) };
let mut doc = (*it.doc).clone();
let mut sel = it.selection.clone();
// What the gesture changes on the selected layers is an explicit edit.
+7
View File
@@ -206,6 +206,13 @@ pub fn gradient_dragging(ctx: &egui::Context) -> bool {
matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Gradient { .. }))
}
/// Whether the open image fill's crop (its handles, or a drag on the image) is being dragged, the
/// press that starts it included: the open colour picker holds that paint in Crop mode, so the
/// press is its own control, not one outside it that would close it and commit the drag (#2307).
pub fn crop_dragging(ctx: &egui::Context) -> bool {
matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Crop { .. }))
}
/// Whether the document has a gesture open: a handle drag edits only inside its own.
fn gesture_open(app: &FigApp) -> bool {
app.session.ds().is_ok_and(|d| d.interaction.is_some())
+5 -1
View File
@@ -398,7 +398,11 @@ fn send(app: &mut FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers) {
app.canvas.pressure = None;
}
app.canvas.last_pointer = Some(Point::new(ev.x, ev.y));
if let Err(e) = app.session.pointer(&ev) {
// A gesture something else committed under the held button (a colour picker closing on this
// press) leaves the rest of its drag with nothing to edit: that is not worth saying.
if let Err(e) = app.session.pointer(&ev)
&& !matches!(e, figcraft_engine::EngineError::NoGesture)
{
app.toast(e.to_string());
}
app.canvas.last_hover = hover.then(|| HoverKey::of(app, ev));
+50
View File
@@ -194,6 +194,56 @@ fn crop_chosen_in_the_colour_picker_enters_crop_mode() {
assert!((scaled[0] - after[0] * 5.0 / 6.0).abs() < 1e-6 && (scaled[3] - after[3] * 5.0 / 6.0).abs() < 1e-6, "{after:?} -> {scaled:?}");
}
/// The reporter's sequence (#2307): the colour picker open on an image fill held in Crop mode
/// (Crop chosen in its mode dropdown, or reopened on a fill that already is), then a press on the
/// layer and a drag. The drag moves the picture inside the layer (its crop), not the layer, the
/// press doesn't close the picker under it, and no internal gesture message reaches the user.
#[test]
fn crop_from_the_colour_picker_then_a_drag_on_the_image_moves_the_crop_not_the_layer() {
let (mut u, layer, _) = setup();
u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![layer] });
for _ in 0..4 {
u.frame(vec![]);
}
choose_mode(&mut u, "Fill", "Crop");
// Crop mode on the canvas ended (a click away from the image); the picker opens on the Crop fill.
u.app.ui.crop = None;
u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![layer] });
for _ in 0..4 {
u.frame(vec![]);
}
let (_, Some(before)) = mode(&u, layer) else { panic!("a crop") };
let at = u.app.session.doc().unwrap().node(layer).unwrap().transform.as_coeffs();
let from = u.app.to_screen(100.0, 50.0);
u.drag_to(from, from + vec2(30.0, 10.0));
u.frame(vec![]);
u.button(from + vec2(30.0, 10.0), false);
let n = u.app.session.doc().unwrap().node(layer).unwrap().clone();
assert_eq!(n.transform.as_coeffs(), at, "the layer stays where it is");
assert_eq!((n.width, n.height), (200.0, 100.0), "and keeps its size");
let (m, Some(after)) = mode(&u, layer) else { panic!("a crop") };
assert_eq!(m, ImageScaleMode::Crop);
assert_ne!(after, before, "the drag moved the picture inside the layer");
assert_eq!(u.app.session.selection(), vec![layer]);
assert!(u.app.ui.toast.as_ref().is_none_or(|(t, _)| !t.contains("gesture")), "no internal gesture message: {:?}", u.app.ui.toast);
}
/// A drag the colour picker's close committed under the held button (a press on a layer closing
/// the picker) leaves the rest of its drag with no gesture: that says nothing to the user.
#[test]
fn a_drag_whose_gesture_was_committed_under_the_button_shows_no_message() {
let (mut u, layer, _) = setup();
u.app.run("select.set", json!({"ids": [layer]}));
let from = u.app.to_screen(100.0, 50.0);
u.button(from, true);
u.app.session.commit_interaction().unwrap();
for k in 1..=10 {
u.frame(vec![Event::PointerMoved(from + vec2(3.0, 1.0) * k as f32)]);
}
u.button(from + vec2(30.0, 10.0), false);
assert!(u.app.ui.toast.is_none(), "no message: {:?}", u.app.ui.toast);
}
/// The first paint, an image, of `id`'s fills (`fills`) or strokes, or of region `region` of a vector.
fn image_at(u: &Ui, id: NodeId, fills: bool, region: Option<usize>) -> figcraft_color::ImagePaint {
let n = u.app.session.doc().unwrap().node(id).unwrap();
+2 -1
View File
@@ -1041,7 +1041,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
let picking = app.session.tool.pick_only || app.session.tool.picked.is_some();
// Nor may a press on the open gradient's handles or stops on the canvas: they are this picker's
// controls, and the canvas took the press before the picker drew (#1852).
let own = picking || crate::handles::gradient_dragging(ctx);
// Nor may one on the crop of an image paint held in Crop mode (#2307).
let own = picking || crate::handles::gradient_dragging(ctx) || crate::handles::crop_dragging(ctx);
pop = pop.close_on_outside(!own);
// Only the opening control is held out: any other sidebar or canvas press dismisses it.
let mut held: Vec<Rect> = app.ui.picker_button.into_iter().collect();
+2 -1
View File
@@ -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); 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 (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); CLine truncated
> **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
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:
@@ -217,6 +217,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
| Date | Change | Summary |
|---|---|---|
| 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` |
| 2026-11-12 | patch | M5, #1560: the context menu's Select layer lists locked layers with a padlock in the leading column (the Layers panel's padlock icon; a row both selected and locked shows the check) and picking one selects it, as Lock and unlock layers' note says and as measured in Figma's web app, 2026-11-10; the submenu shows only with more than one layer under the pointer (measured on a single locked layer only: the least certain rule). Left clicks, hover and marquee still never reach a locked layer. `select.layersAt` lists locked layers too and its rows gain `locked`. Tests: `layers_at_a_point_list_locked_layers_with_a_flag_and_select_set_takes_them`, `select_layer_marks_locked_layers_with_a_padlock_and_picks_them`, `select_layer_needs_more_than_one_layer_under_the_pointer`. Counts unchanged |
| 2026-11-09 | patch | Zoom to selection: the selection is fitted with a 40 pt margin into the canvas above the floating bottom toolbar (`ViewState::bottom_inset`, set by the UI each frame, never saved; `view.set` takes `bottomInset`) and centred in that area, as measured in Figma's web app on 2026-11-09. Zoom to fit is unchanged (margin not measured yet). Numbers unchanged. |