From 64f254a823a9ffb9b2f110dcc7fb71a151efb8fc Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 5 Nov 2026 16:07:52 -0500 Subject: [PATCH] Colour picker: dragging a gradient handle on the canvas keeps the picker and the handles up (#1852) (#1985) * M1: dragging a gradient handle on the canvas keeps the colour picker open (#1852) A press on one of the open gradient's handles or stops closed the picker: the canvas took the press and began the drag, then the picker, drawn later in the same frame, saw a press outside its card and closed, committing the drag's interaction at once. The handles vanished with the picker and the drag went on without an undo step of its own. The handles are the picker's controls: while a gradient handle or stop drags (`handles::gradient_dragging`, the press included) the picker doesn't close on an outside press, as it already doesn't while the eyedropper samples. A press elsewhere on the canvas still closes it. Docs: ui-parity.md. Co-Authored-By: Claude Fable 5.1 * M1: a handle drag released off the canvas ends there (#1852) Review: a gradient handle drag whose button went up over a panel, over the colour picker or outside the window stayed stashed, its interaction uncommitted, until the pointer was back on the canvas, and the next press there moved the handle to wherever it landed; with the picker now held open while a gradient drag is stashed, it would also have stayed open meanwhile. `handles::end_released_drag` commits and clears a stashed drag when the button is up, called from the canvas input's off-canvas branch and when the pointer is over something drawn above the canvas. Test: `a_gradient_drag_released_off_the_canvas_ends_there` (released over the sidebar and over the picker). Co-Authored-By: Claude Fable 5.1 * M1: a handle drag ends on a release over a ruler, and when Escape or Undo closes its gesture (#1852) The release check ran only where the pointer was not on the canvas, after the comment pins and ruler guides had their turn; both return early, so a gradient handle released over a ruler stayed dragged, uncommitted, until the next press. It now runs first, wherever the pointer is, and the frame it ends a drag on is the handle's, as the release on the handle itself was. Escape (the picker closes and commits) or Undo can also close the gesture while the button is still down. The stashed drag then kept setting the fill every frame with no gesture open, one undo step a frame; with no gesture open the drag is now over. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/ui-egui/src/canvas/handles.rs | 32 ++++ crates/ui-egui/src/canvas/input.rs | 8 + .../src/colorpicker_selection_tests.rs | 157 ++++++++++++++++++ crates/ui-egui/src/widgets/colorpicker.rs | 5 +- docs/ui-parity.md | 7 +- 5 files changed, 205 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas/handles.rs b/crates/ui-egui/src/canvas/handles.rs index 137e2c92b..949819b03 100644 --- a/crates/ui-egui/src/canvas/handles.rs +++ b/crates/ui-egui/src/canvas/handles.rs @@ -185,6 +185,33 @@ fn drag_id() -> Id { Id::unique("figcraft_handle_drag") } +/// Whether a gradient handle or stop on the canvas is being dragged, the press that starts it +/// included: those are the open colour picker's own controls, so the picker stays through the drag +/// instead of taking the press for one outside it (#1852). +pub fn gradient_dragging(ctx: &egui::Context) -> bool { + matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Gradient { .. })) +} + +/// 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()) +} + +/// A handle drag whose button went up wherever the pointer was (off the canvas, over a panel, the +/// colour picker or a ruler, outside the window) ends as a release on the handle would: the edit +/// commits now, instead of waiting, uncommitted, to move the handle to wherever the pointer next +/// presses. So does one whose gesture Escape or Undo closed under the held button: it stops there +/// rather than editing on with no gesture open, an undo step a frame. True when a drag ended. +pub fn end_released_drag(app: &mut FigApp, ctx: &egui::Context) -> bool { + let stashed = ctx.data(|d| d.get_temp::(drag_id())).is_some(); + if !stashed || (ctx.input(|i| i.pointer.primary_down()) && gesture_open(app)) { + return false; + } + let _ = app.session.commit_interaction(); + ctx.data_mut(|d| d.remove::(drag_id())); + true +} + /// The gradient being edited (single node, picker open on a gradient paint). fn gradient_target(app: &FigApp) -> Option<(NodeId, bool, usize, figcraft_color::Gradient)> { let Some(PickerTarget::Paint { fills, index, ids }) = &app.ui.picker else { return None }; @@ -516,6 +543,11 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { }); let dragging: Option = ctx.data(|d| d.get_temp(drag_id())); if let Some(drag) = dragging { + // Escape or Undo closed the gesture under the held button: the drag is over. + if !gesture_open(app) { + ctx.data_mut(|d| d.remove::(drag_id())); + return true; + } if down { apply(app, &drag, pos, shift); ctx.set_cursor_icon(egui::CursorIcon::Grabbing); diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 5aa054163..3ef5d7574 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -183,6 +183,8 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo if !ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle) || i.pointer.primary_down()) { app.canvas.middle_pan = None; } + // So is a handle drag. + crate::handles::end_released_drag(app, &ctx); return true; }; // A press on the canvas gives the keyboard back to it (arrows nudge again). @@ -230,6 +232,12 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo } return true; } + // A handle drag whose button went up ends first, wherever on the canvas the pointer is: over a + // ruler or a comment pin, which take the pointer below, or over something drawn above the + // canvas (the colour picker, a card). The handle had the press, so it has the release too. + if crate::handles::end_released_drag(app, &ctx) { + return false; + } if !app.canvas.dragging && (resp.hovered() || app.ui.comments.dragging()) && crate::comments::input(app, &ctx, pos) { return false; } diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs index 1e0fe8d1d..e791e0ca9 100644 --- a/crates/ui-egui/src/colorpicker_selection_tests.rs +++ b/crates/ui-egui/src/colorpicker_selection_tests.rs @@ -215,3 +215,160 @@ fn a_press_in_the_gradient_variable_picker_keeps_the_colour_picker_open() { assert!(u.app.ui.picker.is_some(), "the child variable picker is part of the interaction"); u.button(at, false); } + +#[test] +fn dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open() { + // #1852: the handles belong to the picker, so the press that starts a drag is not a press + // outside it: the picker and the handles stay, the drag is one undo step, and a press elsewhere + // on the layer still closes it. + let mut s = Session::new(); + let a = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap()["id"].as_u64().unwrap()); + s.execute("fill.set", &json!({"id": a, "paint": {"type": "linearGradient", "stops": ["#ff0000", "#0000ff"]}})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }; + u.app.open_picker(target.clone(), None); + for _ in 0..5 { + u.frame(vec![]); + } + assert_eq!(u.app.ui.picker, Some(target.clone())); + let depth = u.app.session.undo_depth().0; + let from_handle = |u: &Ui| match &u.app.session.doc().unwrap().node(a).unwrap().fills[0].kind { + figcraft_color::PaintKind::Gradient(g) => g.from, + _ => panic!("the fill is a gradient"), + }; + assert_eq!(from_handle(&u), (0.5, 0.0), "a linear gradient starts at the top middle"); + // Drag the start handle from the top middle of the layer to its left middle. + let from = u.app.to_screen(200.0, 100.0); + let to = u.app.to_screen(100.0, 200.0); + u.button(from, true); + assert_eq!(u.app.ui.picker, Some(target.clone()), "the press on the handle keeps the picker open"); + for _ in 0..2 { + u.frame(vec![egui::Event::PointerMoved(to)]); + } + assert_eq!(u.app.ui.picker, Some(target.clone()), "and so does the drag"); + let (x, y) = from_handle(&u); + assert!(x.abs() < 0.02 && (y - 0.5).abs() < 0.02, "the handle followed the pointer: ({x}, {y})"); + u.button(to, false); + u.frame(vec![]); + assert_eq!(u.app.ui.picker, Some(target.clone()), "released, the picker is still open"); + assert_eq!(u.app.session.undo_depth().0, depth + 1, "the drag is one undo step"); + // A press on the layer away from its handles is still a press outside the picker. + let body = u.app.to_screen(250.0, 250.0); + u.button(body, true); + u.button(body, false); + u.frame(vec![]); + assert!(u.app.ui.picker.is_none(), "a plain canvas press closes it"); +} + +#[test] +fn a_gradient_drag_released_off_the_canvas_ends_there() { + // A handle drag whose button goes up over the sidebar or over the picker itself ends there: + // the edit commits, the picker stays, and the next press on the canvas is a plain press (it + // closes the picker) rather than a move of the handle to wherever it landed. + for over in ["sidebar", "picker"] { + let mut s = Session::new(); + let a = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap()["id"].as_u64().unwrap()); + s.execute("fill.set", &json!({"id": a, "paint": {"type": "linearGradient", "stops": ["#ff0000", "#0000ff"]}})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }; + u.app.open_picker(target.clone(), None); + for _ in 0..5 { + u.frame(vec![]); + } + let depth = u.app.session.undo_depth().0; + let from_handle = |u: &Ui| match &u.app.session.doc().unwrap().node(a).unwrap().fills[0].kind { + figcraft_color::PaintKind::Gradient(g) => g.from, + _ => panic!("the fill is a gradient"), + }; + u.button(u.app.to_screen(200.0, 100.0), true); + u.frame(vec![egui::Event::PointerMoved(u.app.to_screen(100.0, 200.0))]); + assert!(crate::handles::gradient_dragging(&u.ctx), "{over}: the handle is being dragged"); + let spot = match over { + "sidebar" => u.app.ui.right_panel_rect.unwrap().center(), + _ => u.app.ui.picker_popover.as_ref().unwrap().rect.center(), + }; + if over == "picker" { + assert!(u.app.canvas.rect.contains(spot), "the picker lies over the canvas"); + } else { + assert!(!u.app.canvas.rect.contains(spot), "the sidebar is off the canvas"); + } + u.button(spot, false); + u.frame(vec![]); + assert!(!crate::handles::gradient_dragging(&u.ctx), "{over}: the release ended the drag"); + assert_eq!(u.app.ui.picker, Some(target.clone()), "{over}: the picker is still open"); + assert_eq!(u.app.session.undo_depth().0, depth + 1, "{over}: the drag is one undo step"); + let moved = from_handle(&u); + assert!(moved.0.abs() < 0.02 && (moved.1 - 0.5).abs() < 0.02, "{over}: the handle stayed where it was dragged: {moved:?}"); + // The next press on the layer is a plain one: the handle stays put and the picker closes. + let body = u.app.to_screen(250.0, 250.0); + u.button(body, true); + u.frame(vec![]); + assert_eq!(from_handle(&u), moved, "{over}: a later press doesn't move the handle"); + assert!(u.app.ui.picker.is_none(), "{over}: a later press closes the picker as any outside press does"); + u.button(body, false); + } +} + +/// A rectangle with a linear gradient fill, selected, its picker open and the canvas settled; a +/// second layer drawn after it leaves an undo step that isn't the gradient's. Rulers on or off. +fn gradient_ui(rulers: bool) -> Ui { + let mut s = Session::new(); + if rulers { + s.execute("view.rulers", &json!({"on": true})).unwrap(); + } + let a = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap()["id"].as_u64().unwrap()); + s.execute("fill.set", &json!({"id": a, "paint": {"type": "linearGradient", "stops": ["#ff0000", "#0000ff"]}})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 20, "height": 20})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + u.app.open_picker(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }, None); + for _ in 0..5 { + u.frame(vec![]); + } + u +} + +#[test] +fn a_gradient_drag_released_over_a_ruler_ends_there() { + // The rulers take the pointer on the canvas before the handles do: the release still ends the + // drag, as one undo step, instead of leaving it waiting for the next press. + let mut u = gradient_ui(true); + let depth = u.app.session.undo_depth().0; + u.button(u.app.to_screen(200.0, 100.0), true); + u.frame(vec![egui::Event::PointerMoved(u.app.to_screen(100.0, 200.0))]); + assert!(crate::handles::gradient_dragging(&u.ctx), "the handle is being dragged"); + let r = u.app.canvas.rect; + let ruler = egui::Pos2::new(r.min.x + 5.0, r.center().y); + assert!(!u.app.ui.picker_popover.as_ref().unwrap().rect.contains(ruler), "the ruler is not under the picker"); + u.button(ruler, false); + assert!(!crate::handles::gradient_dragging(&u.ctx), "the release over the ruler ended the drag"); + assert_eq!(u.app.session.undo_depth().0, depth + 1, "the drag is one undo step"); +} + +#[test] +fn a_gradient_drag_whose_gesture_closes_under_the_held_button_stops_editing() { + // Escape (the picker closes and commits) or Undo ends the gesture while the button is still + // down: moving on edits nothing more, rather than adding an undo step every frame. + for close in ["escape", "undo"] { + let mut u = gradient_ui(false); + u.button(u.app.to_screen(200.0, 100.0), true); + u.frame(vec![egui::Event::PointerMoved(u.app.to_screen(100.0, 200.0))]); + assert!(crate::handles::gradient_dragging(&u.ctx), "{close}: the handle is being dragged"); + if close == "escape" { + crate::colorpicker::close(&mut u.app); + } else { + assert!(u.app.session.undo().unwrap(), "{close}: there is a step to undo"); + } + let depth = u.app.session.undo_depth().0; + let doc = u.app.session.doc().unwrap().clone(); + for k in 1..=4 { + u.frame(vec![egui::Event::PointerMoved(u.app.to_screen(100.0 + 20.0 * f64::from(k), 200.0))]); + } + assert!(!crate::handles::gradient_dragging(&u.ctx), "{close}: the drag is over"); + assert_eq!(u.app.session.undo_depth().0, depth, "{close}: no undo step a frame"); + assert!(*u.app.session.doc().unwrap() == doc, "{close}: the gradient stays where the gesture left it"); + u.button(u.app.to_screen(180.0, 200.0), false); + } +} diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index 5fbcc7b40..6ba6af305 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -977,7 +977,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { // Sampling a colour from the canvas — the eyedropper this picker's own button started, or the // colour it just sampled — is the press, not a press outside: neither may close the picker. let picking = app.session.tool.pick_only || app.session.tool.picked.is_some(); - pop = pop.close_on_outside(!picking); + // 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); + 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 = app.ui.picker_button.into_iter().collect(); held.extend(crate::variable_ui::picker_rect(ctx)); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cc0fc9db8..d98a6ed73 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-11-02 · **Change:** minor (a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** patch (a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **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: @@ -30,7 +30,7 @@ interaction feel not yet compared. | Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) | | Fields select their text on focus | done | Number and text values keep their vertical position when their editor gains focus (including typography spacing; `sidebar_field_text_stays_in_place_on_focus`). a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click. A rename (double-click, F2, File ▸ Rename, a page) selects the whole name before its field first draws, so a key typed in that same frame replaces the name; it was appended (`widgets::select_all_on_focus`, `select_all_tests`, `layers_rename_tests`) | | Colour picker models | done | Hex, RGB, HSL, HSB, LCH, OKLCH and CSS; LCH and OKLCH keep lightness and hue when a colour leaves sRGB (chroma comes down); the values typed stay while the colour is theirs (a hue typed on a grey, a chroma sRGB can't hold); CSS reads `lch()` and `oklch()` with any hue unit and `none` (`figcraft_color::{Lch, Oklch}`). Values are the stored channels read as sRGB | -| Colour picker placement and dismissal | done | Opens 8 pt beside the sidebar at the opening control, lifted to fit the screen; outside presses (including the sidebar), Escape and X close it. Reopening resets a dragged position; eyedropper sampling keeps it open. | +| Colour picker placement and dismissal | done | Opens 8 pt beside the sidebar at the opening control, lifted to fit the screen; outside presses (including the sidebar), Escape and X close it. Reopening resets a dragged position; eyedropper sampling keeps it open, and so does a drag of the open gradient's handles or stops on the canvas (#1852). | | Colour picker image well | partial | the Image tab shows a checkered placeholder and Upload from computer until there is a file (no dialog at once); an image paint shows its asset preview; a file dropped on the open picker replaces the image, keeping the fill mode, crop, position and adjustments (`image.replace`). Layers' fills and strokes only (text ranges, styles and selection colours keep Upload). The Fill and Stroke swatches take no drop: a dragged file's position isn't known, so a drop with none goes to the open picker | | Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | @@ -113,7 +113,7 @@ interaction feel not yet compared. | Masks: what a mask hides doesn't take clicks; a mask group's box is the mask's | done | | | Layout grids (Ctrl+G) | done | columns, rows and grid; stretch, left, centre, right; Auto count; several per frame; styles; not shown or snapped on a rotated frame; a nested frame's grids snap but only top-level frames draw them | | Gamut warnings for Display P3 files | done | colour picker note; View ▸ Soft-proof sRGB stripes what sRGB can't show (the canvas itself is sRGB) | -| Gradient and padding/gap handles on the canvas | done | | +| Gradient and padding/gap handles on the canvas | done | the gradient's handles and stops show while its picker is open, and dragging one keeps the picker open (#1852) | | Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots; the outline follows the layer's geometry, rounded corners (each radius and the smoothing) included, whatever the stroke alignment (#1506) | | Cursors per tool and modifier | done | the engine names a cursor for every tool, modifier and hover target (`tool.overlay.cursor`, tabled in `cursor_tests`) and the UI shows the system's or draws its own at the pointer (`cursors.rs`, `cursors_tests`; original glyphs from lines and the code-drawn icons). Arrow for Move and Scale; two-headed arrows on the handles that follow the handle and the box's rotation; outside a corner a curved rotate arrow turned to that corner, turning with the pointer while rotating; crosshair for the frame, section, slice, shape, pencil and Text tools; the I-beam over a text with the Text tool and over the text being edited; the pen as a cross with what a click does beside it (nothing: a new point; a ring: closes, connects to or goes on from a point; a plus: adds a point on a segment, also Alt in vector edit mode; a curve: bends, the Bend tool or Cmd/Ctrl); open and closed hand for the Hand tool, Space and the middle button; zoom in and out (Alt) while Z is held; eyedropper; comment pin; the copy cursor with Alt over a selected layer and while Alt-dragging; not-allowed while a drag resizes, rotates or moves a layer inside an instance (it is undone on release) and over a layers row that takes no drop. Ruler guides and gradient, crop and padding handles keep their own cursors (the tool's used to replace them). `ui.inspect` reports `cursor`. Left: no cursor for removing a point with the pen (the pen doesn't remove points); the drawn cursors follow the pointer a frame behind the system's; not compared with Figma's side by side | | Multiplayer cursors, cursor chat, spotlight | partial | in live sessions: named cursors, chat bubbles that fade, `/` to chat, spotlight from your avatar's menu with a banner; no audio or reactions | @@ -178,6 +178,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-04 | patch | M1, #1852: a press on one of the open gradient's handles or stops on the canvas is the colour picker's own, not a press outside it (Figma keeps the picker and the handles up while a handle or stop is dragged; the stops are made to be dragged: "You can click and drag a stop to change its placement on the gradient", Use gradients as a fill or stroke). The canvas took the press and began the drag, then the picker, drawn later in the same frame, saw a press outside its card and closed, committing the drag's interaction at once; the handles vanished with it and the drag went on without an undo step of its own. Now the picker stays while a gradient handle or stop drags, and the drag is one undo step; a press elsewhere on the canvas still closes it. A handle drag whose button goes up off the canvas (over a panel or the picker) ends there too, instead of waiting, uncommitted, to move the handle to the next press on the canvas. Tests: `colorpicker_selection_tests::dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open`, `colorpicker_selection_tests::a_gradient_drag_released_off_the_canvas_ends_there`. | | 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. | | 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. | | 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` |