mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 05:42:38 +00:00
Merge pull request #2163 from TrevorBrown-dev/fix/caret-colour-2156
M3: the text caret takes the text's fill colour (#2156)
This commit is contained in:
2 files changed
+158
-2
No files matched your search
@@ -209,6 +209,61 @@ fn layout_grids(app: &FigApp, painter: &egui::Painter, doc: &Document, c: NodeId
|
||||
}
|
||||
}
|
||||
|
||||
/// The solid colour of the fill the next typed character gets (the character before the caret,
|
||||
/// the first one's at 0, the base style for an empty text): the style's own fills when it has
|
||||
/// them, else the layer's. `None` when the topmost visible paint isn't a solid (gradient, image,
|
||||
/// no visible fill), so the caller uses [`caret_fallback`]. Opaque on purpose: Figma's caret
|
||||
/// is one colour, and the fill's and paint's opacity weren't measured, so it must stay visible.
|
||||
fn caret_fill(n: &figcraft_doc::Node, caret: usize) -> Option<figcraft_color::Color> {
|
||||
let t = n.kind.text()?;
|
||||
let style = t.style_at(caret.saturating_sub(1));
|
||||
let paints = style.fills.as_ref().unwrap_or(&n.fills);
|
||||
paints.iter().rev().find(|p| p.visible).and_then(|p| match &p.kind {
|
||||
figcraft_color::PaintKind::Solid { color } => Some(color.with_alpha(1.0)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`caret_fill`] as a screen colour.
|
||||
fn caret_color(ctx: &egui::Context, n: &figcraft_doc::Node, caret: usize) -> Option<Color32> {
|
||||
caret_fill(n, caret).map(|c| doc_c32(ctx, c))
|
||||
}
|
||||
|
||||
/// The caret of a text without a solid fill (a gradient, an image, no visible fill): black on a
|
||||
/// light surface, white on a dark one. The surface is the nearest layer around the text whose
|
||||
/// topmost visible fill is a solid at least half opaque as drawn (its colour's alpha times its paint's
|
||||
/// opacity times its own and its containers' layer opacity), else the page; a layer whose top fill is a gradient or an
|
||||
/// image is passed over. Not the interface's text colour, which is white in the dark theme and
|
||||
/// vanishes on the default light page (#2190).
|
||||
fn caret_fallback(doc: &Document, id: NodeId) -> Color32 {
|
||||
let chain = doc.ancestors(id);
|
||||
// Each layer's opacity as drawn: its own times every container's above it.
|
||||
let drawn: Vec<f32> = {
|
||||
let mut acc = 1.0f32;
|
||||
let mut out: Vec<f32> = chain
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|a| {
|
||||
acc *= doc.node(*a).map_or(1.0, |n| if n.opacity.is_finite() { n.opacity.clamp(0.0, 1.0) } else { 1.0 });
|
||||
acc
|
||||
})
|
||||
.collect();
|
||||
out.reverse();
|
||||
out
|
||||
};
|
||||
let under = chain.iter().zip(drawn).find_map(|(a, alpha)| {
|
||||
let n = doc.node(*a)?;
|
||||
match &n.kind {
|
||||
NodeKind::Page { background, .. } => Some(*background),
|
||||
_ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| match &p.kind {
|
||||
figcraft_color::PaintKind::Solid { color } if color.a * p.opacity * alpha >= 0.5 => Some(*color),
|
||||
_ => None,
|
||||
}),
|
||||
}
|
||||
});
|
||||
if under.is_some_and(|c| c.is_dark()) { Color32::WHITE } else { Color32::BLACK }
|
||||
}
|
||||
|
||||
fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, te: &figcraft_engine::TextEdit, zoom: f64) {
|
||||
let preedit = app.preedit_view();
|
||||
let edoc: &Document = preedit.map(|p| &*p.doc).unwrap_or(doc);
|
||||
@@ -242,7 +297,8 @@ fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Do
|
||||
let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret);
|
||||
let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0;
|
||||
if blink || a != b {
|
||||
painter.line_segment(text.caret(app, caret, zoom), stroke(t.text, 1.0));
|
||||
let col = edoc.node(te.id).and_then(|n| caret_color(painter.ctx(), n, caret)).unwrap_or_else(|| caret_fallback(edoc, te.id));
|
||||
painter.line_segment(text.caret(app, caret, zoom), stroke(col, 1.0));
|
||||
}
|
||||
painter.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
@@ -782,3 +838,99 @@ fn pick_outline(app: &FigApp, painter: &egui::Painter, doc: &Document) {
|
||||
draw_outline(app, painter, doc, p, stroke(crate::handles::PINK, 2.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod caret_tests {
|
||||
use super::*;
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Node, NodeId, NodeKind, StyleRun, TextData};
|
||||
|
||||
fn text(chars: &str, fills: Vec<Paint>) -> Node {
|
||||
let mut n = Node::new(NodeId(1), NodeKind::Text(TextData { characters: chars.into(), ..TextData::default() }), "Text", 100.0, 20.0);
|
||||
n.fills = fills;
|
||||
n
|
||||
}
|
||||
|
||||
fn hex(s: &str) -> Color {
|
||||
Color::from_hex(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn red_text_has_a_red_caret() {
|
||||
let n = text("Caret", vec![Paint::solid(hex("#FF0000"))]);
|
||||
assert_eq!(caret_fill(&n, 2), Some(hex("#FF0000")));
|
||||
assert_eq!(caret_color(&egui::Context::default(), &n, 2), Some(Color32::from_rgb(255, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_grey_text_keeps_its_own_colour_whatever_is_behind_it() {
|
||||
// Inside a #222222 frame: the frame's fill plays no part.
|
||||
let n = text("Caret", vec![Paint::solid(hex("#DDDDDD"))]);
|
||||
assert_eq!(caret_fill(&n, 5), Some(hex("#DDDDDD")));
|
||||
assert_eq!(caret_color(&egui::Context::default(), &n, 0), Some(Color32::from_rgb(0xDD, 0xDD, 0xDD)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_caret_follows_the_run_of_the_character_before_it() {
|
||||
let mut n = text("ab cd", vec![Paint::solid(hex("#000000"))]);
|
||||
if let Some(t) = n.kind.text_mut() {
|
||||
let mut style = t.style.clone();
|
||||
style.fills = Some(vec![Paint::solid(hex("#FF0000"))]);
|
||||
t.runs.push(StyleRun { start: 3, end: 5, style });
|
||||
}
|
||||
assert_eq!(caret_fill(&n, 2), Some(hex("#000000")), "after a black character");
|
||||
assert_eq!(caret_fill(&n, 4), Some(hex("#FF0000")), "after a red character");
|
||||
assert_eq!(caret_fill(&n, 0), Some(hex("#000000")), "at the start: the first character's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_caret_is_opaque_whatever_the_fill_opacity() {
|
||||
let mut p = Paint::solid(hex("#336699"));
|
||||
p.opacity = 0.2;
|
||||
assert_eq!(caret_fill(&text("x", vec![p]), 1).map(|c| c.a), Some(1.0));
|
||||
}
|
||||
|
||||
/// #2190: a caret without a solid fill reads on the page: black on the default light page,
|
||||
/// white on a dark one (the interface's text colour was white in the dark theme).
|
||||
#[test]
|
||||
fn without_a_solid_fill_the_caret_contrasts_with_the_page() {
|
||||
let mut s = figcraft_engine::Session::new();
|
||||
let r = s.execute("text.create", &serde_json::json!({"text": "Caret", "x": 0, "y": 0})).unwrap();
|
||||
let id = figcraft_engine::params::id_of(&r["id"]).unwrap();
|
||||
assert_eq!(caret_fallback(s.doc().unwrap(), id), Color32::BLACK, "the default light page");
|
||||
s.execute("page.setBackground", &serde_json::json!({"color": "#1E1E1E"})).unwrap();
|
||||
assert_eq!(caret_fallback(s.doc().unwrap(), id), Color32::WHITE, "a dark page");
|
||||
}
|
||||
|
||||
/// CodeRabbit on #2163: a dark frame on a light page is the surface under its text.
|
||||
#[test]
|
||||
fn without_a_solid_fill_the_caret_contrasts_with_the_frame_around_it() {
|
||||
let mut s = figcraft_engine::Session::new();
|
||||
let ex = |s: &mut figcraft_engine::Session, c: &str, p: serde_json::Value| s.execute(c, &p).unwrap();
|
||||
let f = ex(&mut s, "frame.create", serde_json::json!({"x": 0, "y": 0, "width": 300, "height": 200}));
|
||||
let f = figcraft_engine::params::id_of(&f["id"]).unwrap();
|
||||
ex(&mut s, "node.set", serde_json::json!({"id": f, "props": {"fill": "#222222"}}));
|
||||
let r = ex(&mut s, "text.create", serde_json::json!({"text": "Caret", "x": 20, "y": 20, "parent": f}));
|
||||
let id = figcraft_engine::params::id_of(&r["id"]).unwrap();
|
||||
assert_eq!(caret_fallback(s.doc().unwrap(), id), Color32::WHITE, "inside a dark frame on the light page");
|
||||
ex(&mut s, "node.set", serde_json::json!({"id": f, "props": {"fill": "#F0F0F0"}}));
|
||||
assert_eq!(caret_fallback(s.doc().unwrap(), id), Color32::BLACK, "inside a light frame");
|
||||
// A dark frame drawn at 20% opacity shows the light page through it.
|
||||
ex(&mut s, "node.set", serde_json::json!({"id": f, "props": {"fill": "#222222", "opacity": 0.2}}));
|
||||
assert_eq!(caret_fallback(s.doc().unwrap(), id), Color32::BLACK, "a faint dark frame on the light page");
|
||||
// The same through the colour's own alpha.
|
||||
ex(&mut s, "node.set", serde_json::json!({"id": f, "props": {"fill": "#22222233", "opacity": 1.0}}));
|
||||
assert_eq!(caret_fallback(s.doc().unwrap(), id), Color32::BLACK, "a dark fill at 20% alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_or_missing_fills_have_no_fill_colour() {
|
||||
let mut g = Paint::solid(hex("#FF0000"));
|
||||
g.kind = figcraft_color::PaintKind::Gradient(figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, hex("#FF0000")));
|
||||
assert_eq!(caret_fill(&text("Caret", vec![g]), 2), None);
|
||||
assert_eq!(caret_fill(&text("Caret", vec![]), 2), None);
|
||||
let mut hidden = Paint::solid(hex("#FF0000"));
|
||||
hidden.visible = false;
|
||||
assert_eq!(caret_fill(&text("Caret", vec![hidden]), 2), None);
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (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); 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 thLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (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); 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 thLine 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:
|
||||
@@ -101,6 +101,9 @@ interaction feel not yet compared.
|
||||
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377), and bakes a vector's point corner radius into its path (each rounded point becomes the arc's end points joined by curves, the layer stays a vector and its box fits the real geometry, #2195); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875). Not yet: areas of other layers edited at once |
|
||||
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875); Copy and Cut with points or segments selected take just those (a lone point: the segments touching it) as a vector with the layer's look, at their own place, so a paste outside edit mode is a new layer in place; Cut removes them as Delete does, the path splitting in the same layer with edit mode staying on; Paste while editing adds the clipboard's segments, or a copied shape's or boolean's outline, to the edited network in place with the new points selected, skipping layers with no outline (text, frames, groups; named in `skipped`) and refusing when none has one, as measured in Figma (`vector_clipboard_tests`, #1875). Not yet: areas of other layers edited at once |
|
||||
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
|
||||
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003) |
|
||||
| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; the caret takes the text's solid fill colour, measured in Figma's web app, 2026-11-09; gradient and image fills (not measured) give a black caret on a light surface and a white one on a dark one (the nearest layer around the text whose top visible fill is a solid at least half opaque as drawn, counting layer opacity, else the page; a layer whose top fill is a gradient or an image is passed over), not the interface's text colour, which is white in the dark theme and vanished on the default light page (#2190) (#2156; `caret_tests`); no spell check |
|
||||
| Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. Not yet included: per-paragraph settings, text-style support and the `.fig` import/export mapping (#1827). Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` |
|
||||
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003); a boolean group takes a corner radius from the Design panel and from `node.set cornerRadius`: every sharp corner of its result rounds, convex and concave, layers inside keep their own radius and do not stack with it, a radius larger than the edges allow shrinks to fit while the field keeps the value typed, Flatten bakes the arcs and a mixed selection reads Mixed; measured in Figma's web app on 2026-11-10 (#2222; `crates/pathops/src/round_tests.rs`, `crates/render/src/boolean_radius_tests.rs`, `crates/engine/src/boolean_radius_tests.rs`, the boolean group test in `crates/ui-egui/src/field_tests.rs`). Pinned and not yet compared with Figma: corner smoothing on a boolean group draws the polygon profile between straight edges, a corner beside a curve stays within the curve piece next to it, and there is no radius handle on the canvas |
|
||||
| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
|
||||
| Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. At three levels (the text layer, a paragraph and a text style, per Explore text properties): the paragraph level is the caret's or the selection's paragraphs while editing (`text.wrapStyle`; the control follows them), or `start`/`end` on `node.set wrapStyle` (a paragraph's own style beats the layer's; setting the layer sets every paragraph again; Enter carries it to the new paragraph; inspect reports `lineWrapStyles` when paragraphs differ and CSS `text-wrap` only when they agree); the text-style level is a style's `wrapStyle` (shown read-only in the style editor, applied with the style, moved by `style.update`, unlinking when edited). The `.fig` import/export mapping stays open (#1827): a `.fig` export leaves the wrap styles out and a `.fig` import reads every text as Auto, so the wrap styles are lost through a `.fig` round trip. Test: `text_behaviour_tests::wrap_style_per_paragraph_overrides_the_layers`, `styles_behaviour_tests::text_styles_carry_the_wrap_style`, `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` |
|
||||
@@ -214,6 +217,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-09 | patch | M3, #2156: the text caret takes the text's solid fill colour (red text, red caret; light grey on a dark frame, light grey, one colour and no inversion), measured in Figma's web app: the fill of the character before the caret (the first one's at the start); FigCraft draws it opaque (how Figma treats a translucent fill was not measured); gradient, image and hidden fills (not measured) give a black caret on a light surface and a white one on a dark one (the nearest layer around the text whose top visible fill is a solid at least half opaque as drawn, counting layer opacity, else the page; a layer whose top fill is a gradient or an image is passed over), not the interface's text colour (white in the dark theme, invisible on the default light page, #2190). Width and height unchanged; collaborators' carets unchanged. Tests: `caret_tests` |
|
||||
| 2026-11-10 | minor | The Rotation field shows and sets the layer's rotation on the page (its own angle plus its parents' when no ancestor is flipped; with a flipped ancestor FigCraft reads the composed angle, which flips the sign of the layer's own angle, and Figma's behaviour there was not measured), in frames and groups alike, as measured in Figma's web app for rotated, unflipped parents (#2196) |
|
||||
| 2026-11-09 | patch | M3, #2189: the eyedropper picks the colour the canvas shows. It sampled a 1x1 render without the canvas's magnified-image pixels, so on an image magnified past 2x it blended a 1 px line with its neighbours (#3D444D picked as #343B44 beside #151B23), and the pixel it sampled began at the pointer instead of being centred on it. It now matches the canvas when zoomed in; zoomed out it still samples the full-resolution image, not the smaller copy the canvas may show. Test: `eyedropper_image_tests` |
|
||||
| 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 |
|
||||
|
||||
Reference in new issue
Block a user