Merge main into #2191

This commit is contained in:
bflatastic committed 2026-11-12 13:29:57 +07:00
commit d50203228d
3 files changed
+70 -11

No files matched your search

+24 -4
View File
@@ -1,7 +1,7 @@
//! The pointer on the canvas: each engine cursor ([`figcraft_engine::tools::Cursor`]) as one of
//! the system's cursors, or as a small glyph FigCraft draws at the pointer with the system's
//! hidden (a rotate cursor turned to its corner, the pen's states, the eyedropper, the comment
//! pin). The glyphs are original, drawn from lines and the code-drawn icons: no cursor images.
//! pin, the crosshair of the shape and Text tools). The glyphs are original, drawn from lines and the code-drawn icons: no cursor images.
use egui::{Color32, CursorIcon, Painter, Pos2, Shape, Stroke, pos2, vec2};
use figcraft_engine::tools::{Cursor, PenCursor};
@@ -17,6 +17,9 @@ pub enum Glyph {
Pen(PenCursor),
Eyedropper,
Comment,
/// A plus open at the centre, for the shape and Text tools (the system's follows the
/// system's pointer colour, and a white one is lost on a white frame).
Crosshair,
}
/// What the pointer shows.
@@ -46,7 +49,7 @@ pub fn of(cursor: Cursor) -> Shown {
use Shown::{Drawn, System};
match cursor {
Cursor::Default => System(CursorIcon::Default),
Cursor::Crosshair => System(CursorIcon::Crosshair),
Cursor::Crosshair => Drawn(Glyph::Crosshair),
Cursor::Move => System(CursorIcon::Move),
Cursor::Resize(a) if a.is_finite() => System(resize_icon(a)),
Cursor::Resize(_) => System(CursorIcon::Default),
@@ -85,7 +88,7 @@ impl Shown {
/// A name for it (`ui.inspect`): the CSS cursor names where there is one ("default",
/// "nwse-resize", "copy"), and "rotate" (with its angle), "pen", "pen-close", "pen-add",
/// "pen-bend", "eyedropper" and "comment" for the drawn ones.
/// "pen-bend", "eyedropper", "comment" and "crosshair" for the drawn ones.
pub fn name(self) -> String {
match self {
Shown::System(i) => match i {
@@ -114,6 +117,7 @@ impl Shown {
Shown::Drawn(Glyph::Pen(PenCursor::Bend)) => "pen-bend".into(),
Shown::Drawn(Glyph::Eyedropper) => "eyedropper".into(),
Shown::Drawn(Glyph::Comment) => "comment".into(),
Shown::Drawn(Glyph::Crosshair) => "crosshair".into(),
}
}
@@ -127,7 +131,7 @@ impl Shown {
}
/// Dark lines over a light edge: readable on any page and artwork.
const INK: Color32 = Color32::from_rgb(0x1E, 0x1E, 0x1E);
pub(crate) const INK: Color32 = Color32::from_rgb(0x1E, 0x1E, 0x1E);
const EDGE: Color32 = Color32::WHITE;
/// A line through `pts` with the light edge under it.
@@ -167,6 +171,22 @@ pub fn paint(painter: &Painter, at: Pos2, glyph: Glyph) {
line(painter, &[tip - t * 4.5 + n * 3.5, tip + t * 1.0, tip - t * 4.5 - n * 3.5]);
}
}
Glyph::Crosshair => {
// A plus centred on the pointer, open at the centre so its pixel stays visible; the
// light edge goes under both arms of each line first, then the dark line on top.
let arms = [
(vec2(-8.0, 0.0), vec2(-2.0, 0.0)),
(vec2(2.0, 0.0), vec2(8.0, 0.0)),
(vec2(0.0, -8.0), vec2(0.0, -2.0)),
(vec2(0.0, 2.0), vec2(0.0, 8.0)),
];
for (a, b) in arms {
painter.add(Shape::line_segment([at + a, at + b], Stroke::new(3.0, EDGE)));
}
for (a, b) in arms {
painter.add(Shape::line_segment([at + a, at + b], Stroke::new(1.0, INK)));
}
}
Glyph::Pen(state) => {
// A cross open at the centre (the point it lands on stays visible), and what a click
// does at its lower right.
+43 -5
View File
@@ -22,7 +22,7 @@ fn every_engine_cursor_has_a_system_cursor_or_a_glyph() {
// (engine cursor, what shows, its name in `ui.inspect`)
let rows: Vec<(Cursor, Shown, &str)> = vec![
(Cursor::Default, System(CursorIcon::Default), "default"),
(Cursor::Crosshair, System(CursorIcon::Crosshair), "crosshair"),
(Cursor::Crosshair, Drawn(Glyph::Crosshair), "crosshair"),
(Cursor::Move, System(CursorIcon::Move), "move"),
(Cursor::Resize(0.0), System(CursorIcon::ResizeHorizontal), "ew-resize"),
(Cursor::Resize(90.0), System(CursorIcon::ResizeVertical), "ns-resize"),
@@ -59,6 +59,44 @@ fn every_engine_cursor_has_a_system_cursor_or_a_glyph() {
assert_eq!(of(Cursor::Rotate(f64::INFINITY)), Drawn(Glyph::Rotate(0.0)));
}
#[test]
fn the_crosshair_is_drawn_dark_with_a_white_edge_so_it_shows_on_white() {
use egui::epaint::Shape;
let shown = of(Cursor::Crosshair);
assert_eq!(shown, Shown::Drawn(Glyph::Crosshair));
// The system's pointer is hidden under it, whatever its colour.
assert_eq!(shown.icon(), CursorIcon::None);
let at = pos2(50.0, 40.0);
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let ctx = ui.ctx().clone();
let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("crosshair_test"));
crate::cursors::paint(&ctx.layer_painter(layer), at, Glyph::Crosshair);
});
out.textures_delta.clear();
let mut strokes = Vec::new();
for clipped in &out.shapes {
if let Shape::LineSegment { points, stroke } = &clipped.shape {
strokes.push((points.to_vec(), stroke.color, stroke.width));
}
}
let dark: Vec<_> = strokes.iter().filter(|s| s.1 == crate::cursors::INK).collect();
let white: Vec<_> = strokes.iter().filter(|s| s.1 == egui::Color32::WHITE).collect();
assert!(!dark.is_empty() && !white.is_empty(), "{strokes:?}");
// The white edge is wider than the dark line and is painted first.
assert!(white.iter().all(|w| w.2 > dark[0].2), "{strokes:?}");
assert_eq!(strokes[0].1, egui::Color32::WHITE);
// The arms are centred on the pointer and leave its pixel clear.
for (pts, ..) in &dark {
for p in pts {
let d = (p.x - at.x).abs().max((p.y - at.y).abs());
assert!((2.0..=8.0).contains(&d), "{p:?}");
assert!((p.x - at.x).abs() < 0.01 || (p.y - at.y).abs() < 0.01, "{p:?}");
}
}
assert_eq!(dark.len(), 4);
}
#[test]
fn resize_arrows_follow_the_angle_in_eighths() {
// (degrees, the two-headed arrow): each covers 45° about its own direction, any turn.
@@ -134,10 +172,10 @@ fn the_window_asks_for_the_cursor_of_the_tool_under_the_pointer() {
("move", edge, &[], no, CursorIcon::ResizeHorizontal, "ew-resize"),
("move", on, &[], alt, CursorIcon::Copy, "copy"),
("hand", empty, &[], no, CursorIcon::Grab, "grab"),
("rectangle", empty, &[], no, CursorIcon::Crosshair, "crosshair"),
("frame", on, &[], no, CursorIcon::Crosshair, "crosshair"),
("slice", empty, &[], no, CursorIcon::Crosshair, "crosshair"),
("text", empty, &[], no, CursorIcon::Crosshair, "crosshair"),
("rectangle", empty, &[], no, CursorIcon::None, "crosshair"),
("frame", on, &[], no, CursorIcon::None, "crosshair"),
("slice", empty, &[], no, CursorIcon::None, "crosshair"),
("text", empty, &[], no, CursorIcon::None, "crosshair"),
("pen", empty, &[], no, CursorIcon::None, "pen"),
("comment", empty, &[], no, CursorIcon::None, "comment"),
("move", empty, &[Key::Space], no, CursorIcon::Grab, "grab"),
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (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; 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); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); 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 thLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (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); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); 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 properLine 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:
@@ -130,7 +130,7 @@ interaction feel not yet compared.
| 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 | 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 |
| 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, drawn dark with a white edge so it shows on any canvas colour whatever the system's pointer style (#2198); 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 |
## Keyboard shortcuts
@@ -214,6 +214,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | patch | M3, #2188: Selection colors shows for a selection holding a layer with children (a frame, group, component or instance) from one paint group, and for layers without children only from two paint groups (groups are counted by style, variable and paint value, as `collect` does, not by distinct colours); measured in Figma's web app on 2026-11-09 for the examples below only, and instance and component behaviour was not measured: a frame holding one red rectangle, a group of two red rectangles and a frame with a lone black-stroked rectangle show one row; one red rectangle, two red rectangles and a childless green frame show none; a red and a blue loose rectangle show two (two paint groups). A childless rectangle with a red fill and a blue stroke has two paint groups, so the engine shows the section (not measured). Tests: `selection_colors_tests`. Counts unchanged |
| 2026-11-10 | patch | M1, #2198: with a shape, frame, slice or Text tool the pointer was the system's crosshair, white under some pointer styles on Windows and lost on the default white frame; it is now drawn at the pointer in dark lines with a white edge, open at the centre (`Glyph::Crosshair`), so it shows on any canvas colour. |
| 2026-11-10 | minor | M4, #2222: a boolean group takes a corner radius that rounds every sharp corner of its result (measured in Figma's web app), Boolean groups row |
| 2026-11-08 | minor | M8: measured in Figma on 2026-11-08, vector edit mode's Point section: X and Y show the value the selected points share, or Mixed when they differ, and a typed value sets every selected point's own coordinate in the layer being edited, points of other edited layers left as they are (it moved the group's top-left before); a relative edit (`+10`, `*2`, `/2`) typed into a Mixed field applies to each point's own value (`vector.edit.position` takes a number or `{add}`, `{mul}`, `{div}` per axis); the W and H row and other edited layers' points are unchanged. |
| 2026-11-09 | patch | Preferences ▸ Invert zoom direction (#2145): Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom the other way; pinch and panning unchanged. Numbers unchanged. |