Merge main into #1873

This commit is contained in:
bflatastic committed 2026-11-07 09:28:50 +07:00
commit 5616574bfb
65 files changed
+1512 -199

No files matched your search

+4 -2
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; 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 Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not eachLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; 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 Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (Line truncated
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -63,7 +63,7 @@ native speakers' reviews of the nine language drafts need people.
| Stability | 55% (damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, #1125) | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts; the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian, #2014); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
@@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-11-05** · M10, #2014: the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic (`bidi::script_fallbacks`: one sample letter per script, the system's families for each; Bengali and Thai keep `crate::cjk`'s choice), so a layer name or a String variable's Amharic value draws in the panels instead of boxes. Test: `bidi::tests::other_scripts_take_an_installed_system_face`.
- **2026-11-05** · M4, #1830: a paragraph's base direction can be forced (Text ▸ Text direction: Auto, Left to right, Right to left) as Add right-to-left text says ("controls are available to override text direction per paragraph"). `ParaList::direction`, `text.direction {direction, ids?}` and the three menu commands (the edited paragraphs while editing, else the layer's), `node.set textDirection`, the Type settings control once a right-to-left script is in the text, `textDirection` / `lineDirections` in inspect, the CSS `direction`, and the `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` per paragraph). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests`.
- **2026-11-05** · M1, #2003: the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code from rectangles and polygons (two offset squares, the result filled at reduced alpha); each entry still runs its command.
- **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged.
@@ -360,6 +361,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | patch | M8, #1906: under Wayland a minimised window answers control-channel and attached-MCP requests from its logic passes once it has drawn nothing for 0.2 s while a request waits (about half a second each; every request timed out after 60 s before). Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes`. Numbers unchanged |
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Numbers unchanged |
| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects/<i>/<field>`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Numbers unchanged |
| 2026-11-06 | patch | M9, #2026: a branch merge counts one conflict per auto layout field changed both ways, not one for every size and place auto layout works out from it; values an instance takes from its main no longer conflict, and auto layout settings changed one way survive a conflict on another field. Tests: `branch_tests::one_conflicting_auto_layout_edit_is_one_conflict`, `branch_tests::auto_layout_fields_changed_one_way_survive_a_conflict`, `branch_tests::sizes_auto_layout_works_out_never_conflict`. Numbers unchanged |
+3
View File
@@ -112,3 +112,6 @@ display_name = "Denis Gomes"
[people.Hamza0101]
real_name = "Hamza Naveed"
display_name = "Hamza0101"
[people.ashokram-si]
real_name = "Ashok Ram"
+19 -7
View File
@@ -28,8 +28,9 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
p
}
NodeKind::Vector { network } => {
let f = network.fill_path();
if f.elements().is_empty() { network.stroke_path() } else { f }
let net = drawn_network(network, c.smoothing);
let f = net.fill_path();
if f.elements().is_empty() { net.stroke_path() } else { f }
}
NodeKind::Text(_) => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0),
NodeKind::Page { .. } | NodeKind::Group | NodeKind::BooleanOp { .. } => return None,
@@ -39,11 +40,21 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
/// The open/closed path strokes follow (vector networks stroke every segment, filled or not).
pub fn stroke_geometry(n: &Node) -> Option<BezPath> {
match &n.kind {
NodeKind::Vector { network } => Some(network.stroke_path()),
NodeKind::Vector { network } => Some(drawn_network(network, n.corners.smoothing).stroke_path()),
_ => shape_path(n),
}
}
/// A vector's `network` as the layer draws it: rounded points drawn in with the layer's corner
/// `smoothing` (see [`VectorNetwork::rounded_smoothed`](crate::VectorNetwork::rounded_smoothed)),
/// or the network itself when no point is rounded.
pub fn drawn_network(network: &crate::VectorNetwork, smoothing: f64) -> std::borrow::Cow<'_, crate::VectorNetwork> {
match network.rounded_smoothed(smoothing) {
Some(r) => std::borrow::Cow::Owned(r),
None => std::borrow::Cow::Borrowed(network),
}
}
/// Whether the shape's path is closed (fills apply).
pub fn is_closed(n: &Node) -> bool {
match &n.kind {
@@ -75,8 +86,8 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
n.local_rect().inflate(tol_l, tol_l).contains(lp)
}
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let drawn = drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
if !outline {
for (i, region) in net.regions.iter().enumerate().rev() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
@@ -96,10 +107,11 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
}
/// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of
/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it).
/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains) on the network as drawn: even-odd when any
/// region asks for it).
fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool {
match &n.kind {
NodeKind::Vector { network } => network.fill_contains(lp),
NodeKind::Vector { network } => drawn_network(network, n.corners.smoothing).fill_contains(lp),
_ => path.winding(lp) != 0,
}
}
+6 -2
View File
@@ -416,9 +416,13 @@ impl NodeKind {
self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. })
}
/// Kinds whose rounded corners are drawn with corner smoothing (a vector's rounded points are drawn without it).
/// Kinds whose rounded corners are drawn with corner smoothing: a vector only when its network
/// has a closed region (an open path's rounded points are drawn without it).
pub fn has_corner_smoothing(&self) -> bool {
self.has_corner_radius() && !matches!(self, NodeKind::Vector { .. })
match self {
NodeKind::Vector { network } => !network.regions.is_empty(),
_ => self.has_corner_radius(),
}
}
pub fn text(&self) -> Option<&TextData> {
+149
View File
@@ -679,6 +679,155 @@ fn two_segment_closed_loop_rounded_corners() {
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
let path = net.fill_path();
assert!(!path.elements().is_empty());
// Both points sit next to the curved segment, so smoothing keeps their plain arcs.
assert_eq!(net.rounded_smoothed(0.6), net.rounded(), "a corner next to a curve keeps the plain arc");
}
/// A closed 100 × 100 square whose corners (top left, top right, bottom right, bottom left) have
/// radii `radii`.
fn rounded_square(radii: [f64; 4]) -> VectorNetwork {
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)];
let mut net = VectorNetwork::from_points(&pts, true);
for (v, r) in net.vertices.iter_mut().zip(radii) {
v.radius = r;
}
net
}
/// The area a closed path encloses, whichever way it runs.
fn area_of(p: &figcraft_geom::BezPath) -> f64 {
use figcraft_geom::Shape;
p.area().abs()
}
#[test]
fn closed_vectors_smooth_each_rounded_point_with_its_own_radius() {
use figcraft_geom::Shape;
use figcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
// (point radii, smoothing, derivation): a closed square draws the smoothed rectangle's corners,
// each point keeping its own radius and a point without one staying sharp.
let rows: Vec<([f64; 4], f64, &str)> = vec![
([20.0; 4], 0.6, "one radius on every point"),
([30.0, 0.0, 10.0, 0.0], 0.6, "each point keeps its own radius; points without one stay sharp"),
([12.0; 4], 1.0, "full smoothing"),
([40.0; 4], 0.6, "a corner too large for its edges loses smoothing first"),
];
for (radii, s, why) in rows {
let net = rounded_square(radii);
let smoothed = net.rounded_smoothed(s).unwrap().fill_path();
// The rectangle's corners take [top left, top right, bottom right, bottom left] radii.
let want = area_of(&figcraft_geom::rounded_rect(100.0, 100.0, radii, s));
let got = area_of(&smoothed);
assert!((got - want).abs() < 0.1, "{why}: {got} vs the smoothed rectangle's {want}");
assert!((got - area_of(&net.fill_path())).abs() > 1.0, "{why}: smoothing changes the outline");
let b = smoothed.bounding_box();
assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}");
}
// The sharp points stay on the outline, and the outline has no kinks at the rounded points.
let smoothed = rounded_square([30.0, 0.0, 10.0, 0.0]).rounded_smoothed(0.6).unwrap();
let path = smoothed.fill_path();
for p in [Point::new(100.0, 0.0), Point::new(0.0, 100.0)] {
let d = path.segments().map(|seg| seg.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min);
assert!(d < 1e-12, "the sharp point {p:?} stays a corner");
}
let segs: Vec<_> = path.segments().filter(|seg| seg.arclen(1e-6) > 1e-6).collect();
let mut kinks = 0;
for (i, seg) in segs.iter().enumerate() {
let next = segs[(i + 1) % segs.len()];
let out = (seg.eval(1.0) - seg.eval(1.0 - 1e-6)).normalize();
let into = (next.eval(1e-6) - next.eval(0.0)).normalize();
if out.dot(into) < 0.999 {
kinks += 1;
}
}
assert_eq!(kinks, 2, "only the two sharp points turn without a curve");
assert_eq!(smoothed.regions[0].loops[0].len(), smoothed.segments.len(), "the loop walks every curve once");
}
#[test]
fn corner_smoothing_leaves_open_paths_and_zero_alone() {
let square = rounded_square([20.0; 4]);
assert_eq!(square.rounded_smoothed(0.0), square.rounded(), "no smoothing draws the plain rounded points");
assert_eq!(square.rounded_smoothed(f64::NAN), square.rounded(), "an unusable smoothing draws no smoothing");
// An open path (no closed region) keeps its plain arcs.
let pts = [Point::new(200.0, 0.0), Point::new(250.0, 0.0), Point::new(250.0, 50.0)];
let mut open = VectorNetwork::from_points(&pts, false);
for v in &mut open.vertices {
v.radius = 20.0;
}
assert!(open.regions.is_empty());
assert_eq!(open.rounded_smoothed(0.6), open.rounded(), "an open path takes no smoothing");
// In a network with a closed square and a separate open branch, only the square smooths.
let mut mixed = square.clone();
let base = mixed.vertices.len();
mixed.vertices.extend(open.vertices.iter().copied());
mixed.segments.extend(open.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s }));
let runs = mixed.rounded_smoothed(0.6).unwrap().stroke_runs();
let open_run = runs.iter().find(|r| !r.closed).expect("the open branch");
assert_eq!(open_run.path, open.rounded().unwrap().stroke_runs()[0].path, "the open branch keeps its plain arc");
let closed_run = runs.iter().find(|r| r.closed).expect("the square");
assert_eq!(closed_run.path, square.rounded_smoothed(0.6).unwrap().stroke_runs()[0].path, "the square smooths");
assert_ne!(closed_run.path, square.rounded().unwrap().stroke_runs()[0].path, "the square's smoothed corners differ from its arcs");
}
#[test]
fn smoothed_vector_corners_stay_finite_on_awkward_networks() {
use figcraft_geom::Shape;
// A concave L, a square with a square hole, points with unusable radii and a loop that names a
// missing segment.
let l = [(0.0, 0.0), (100.0, 0.0), (100.0, 40.0), (40.0, 40.0), (40.0, 100.0), (0.0, 100.0)];
let mut concave = VectorNetwork::from_points(&l.map(|(x, y)| Point::new(x, y)), true);
for v in &mut concave.vertices {
v.radius = 1000.0;
}
let mut holed = rounded_square([10.0; 4]);
let hole = rounded_square([5.0; 4]);
let (base, seg_base) = (holed.vertices.len(), holed.segments.len());
holed.vertices.extend(hole.vertices.iter().map(|v| Vertex { x: 25.0 + v.x / 2.0, y: 25.0 + v.y / 2.0, ..*v }));
holed.segments.extend(hole.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s }));
holed.regions[0].loops.push((seg_base..seg_base + hole.segments.len()).collect());
holed.regions[0].winding = WindingRule::EvenOdd;
let mut odd = rounded_square([f64::INFINITY, 10.0, f64::NAN, 10.0]);
odd.regions[0].loops[0].push(99);
for (net, s, why) in [(&concave, 1.0, "concave"), (&holed, 0.6, "holed"), (&odd, 0.6, "odd radii")] {
let drawn = net.rounded_smoothed(s).unwrap();
let b = drawn.fill_path().bounding_box();
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()), "{why}: {b:?}");
assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}");
assert!(!drawn.stroke_path().elements().is_empty(), "{why}");
}
let concave_path = concave.rounded_smoothed(1.0).unwrap().fill_path();
assert_ne!(concave_path, concave.fill_path(), "the concave L smooths");
assert!(concave_path.winding(Point::new(20.0, 60.0)) != 0, "the L stays filled");
let holed_net = holed.rounded_smoothed(0.6).unwrap();
assert!(!holed_net.fill_contains(Point::new(50.0, 50.0)) && holed_net.fill_contains(Point::new(12.0, 50.0)), "the hole stays a hole");
}
#[test]
fn clicks_on_a_smoothed_vector_corner_follow_the_drawn_fill() {
// A closed square with rounded points and smoothing: a click between the plain arc and the
// smoothed corner selects the layer only where the smoothed fill is drawn.
let pts = [Point::new(0.0, 0.0), Point::new(1000.0, 0.0), Point::new(1000.0, 1000.0), Point::new(0.0, 1000.0)];
let mut net = VectorNetwork::from_points(&pts, true);
for v in &mut net.vertices {
v.radius = 200.0;
}
let smoothed = net.rounded_smoothed(1.0).unwrap();
let mut d = Document::new("x");
let p = page(&d);
let v = d.create(p, NodeKind::Vector { network: net.clone() }, "V", 0.0, 0.0, 1000.0, 1000.0).unwrap();
let n = d.node_mut(v).unwrap();
n.corners.smoothing = 1.0;
n.strokes.clear();
// Walk the row 20 units below the top edge inward to where each outline starts covering it.
let first_inside = |inside: &dyn Fn(Point) -> bool| (0..5_000).map(|i| f64::from(i) * 0.1).find(|&x| inside(Point::new(x, 20.0)));
let plain = first_inside(&|pt| net.fill_contains(pt)).unwrap();
let drawn = first_inside(&|pt| smoothed.fill_contains(pt)).unwrap();
assert!((plain - drawn).abs() > 5.0, "smoothing moves the corner: plain {plain}, smoothed {drawn}");
let pt = Point::new((plain + drawn) / 2.0, 20.0);
assert_ne!(net.fill_contains(pt), smoothed.fill_contains(pt));
assert_eq!(d.hit_test(p, pt, 0.0, None).is_some(), smoothed.fill_contains(pt), "the click follows the smoothed corner at {pt:?}");
assert_eq!(d.hit_test(p, Point::new(500.0, 500.0), 0.0, None), Some(v));
}
/// A rectangle whose top-right corner was cut off in the vector editor (a diagonal between a point
+76 -25
View File
@@ -129,9 +129,10 @@ impl Region {
}
}
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again
/// at `b`, and an arc with handles `ta`/`tb` joins them. `cut1`/`cut2`: where each segment is cut,
/// in its own parameter (curved segments keep the rest of their curve).
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), and the curves of `chain`
/// lead from there to where segment `s2` starts again (one arc, or a smoothed corner's easing
/// curves and arc). `cut1`/`cut2`: where each segment is cut, in its own parameter (curved
/// segments keep the rest of their curve).
struct Corner {
v: usize,
s1: usize,
@@ -139,13 +140,19 @@ struct Corner {
s2: usize,
s2_at_a: bool,
a: Point,
b: Point,
ta: (f64, f64),
tb: (f64, f64),
chain: Vec<CornerCurve>,
cut1: f64,
cut2: f64,
}
/// One curve of a rounded point: its handles at its start (`ta`) and its end (`tb`), relative to
/// those ends, and the point it ends at.
struct CornerCurve {
ta: (f64, f64),
tb: (f64, f64),
end: Point,
}
/// Accuracy of arc lengths along curved segments (local units).
const ARCLEN_ACC: f64 = 1e-6;
@@ -382,9 +389,19 @@ impl VectorNetwork {
/// the rest of its curve, the arc leaving along its direction there. `None` when no point is
/// rounded.
pub fn rounded(&self) -> Option<VectorNetwork> {
self.rounded_smoothed(0.0)
}
/// [`Self::rounded`] with the layer's corner smoothing (0..=1): a rounded point between two
/// straight segments that both belong to a region's loops gets a smoothed corner, drawn with
/// the profile of a smoothed polygon's corners (`figcraft_geom::smoothed_corner`). Other
/// rounded points (on open branches, or next to a curved segment) keep the plain arc, and a
/// point without a radius stays sharp. Without smoothing this is [`Self::rounded`].
pub fn rounded_smoothed(&self, smoothing: f64) -> Option<VectorNetwork> {
if !self.vertices.iter().any(|v| v.radius > 0.0) {
return None;
}
let smoothing = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 };
let mut incident: Vec<Vec<(usize, bool)>> = vec![Vec::new(); self.vertices.len()];
for (i, s) in self.segments.iter().enumerate() {
for (v, at_a) in [(s.a, true), (s.b, false)] {
@@ -393,7 +410,14 @@ impl VectorNetwork {
}
}
}
let corners: Vec<Corner> = (0..self.vertices.len()).filter_map(|v| self.corner(v, incident.get(v)?)).collect();
// The segments that bound a closed region, where corners take smoothing.
let mut on_loop = vec![false; if smoothing > 0.0 { self.segments.len() } else { 0 }];
for &i in self.regions.iter().flat_map(|r| r.loops.iter().flatten()) {
if let Some(x) = on_loop.get_mut(i) {
*x = true;
}
}
let corners: Vec<Corner> = (0..self.vertices.len()).filter_map(|v| self.corner(v, incident.get(v)?, smoothing, &on_loop)).collect();
let mut out = self.clone();
for v in &mut out.vertices {
v.radius = 0.0;
@@ -420,26 +444,33 @@ impl VectorNetwork {
s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y);
s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y);
}
let mut by_vertex: std::collections::BTreeMap<usize, usize> = std::collections::BTreeMap::new();
// Each rounded point's curves in order from `s1`'s side, by point.
let mut by_vertex: std::collections::BTreeMap<usize, (usize, Vec<usize>)> = std::collections::BTreeMap::new();
for c in corners {
if let Some(v) = out.vertices.get_mut(c.v) {
(v.x, v.y) = (c.a.x, c.a.y);
}
out.vertices.push(Vertex::new(c.b.x, c.b.y));
let nb = out.vertices.len() - 1;
let mut from = c.v;
let mut arcs = Vec::with_capacity(c.chain.len());
for CornerCurve { ta, tb, end } in c.chain {
out.vertices.push(Vertex::new(end.x, end.y));
let to = out.vertices.len() - 1;
out.segments.push(Segment { a: from, b: to, ta, tb });
arcs.push(out.segments.len() - 1);
from = to;
}
if let Some(s) = out.segments.get_mut(c.s2) {
if c.s2_at_a {
s.a = nb;
s.a = from;
} else {
s.b = nb;
s.b = from;
}
}
out.segments.push(Segment { a: c.v, b: nb, ta: c.ta, tb: c.tb });
let arc = out.segments.len() - 1;
by_vertex.insert(c.v, arc);
by_vertex.insert(c.v, (c.s1, arcs));
}
// The arc goes between the two segments wherever a loop walks through the point. Each loop
// is rebuilt in one pass, so a polygon with many rounded corners isn't quadratic.
// The curves go between the two segments wherever a loop walks through the point, in the
// order the loop meets them. Each loop is rebuilt in one pass, so a polygon with many
// rounded corners isn't quadratic.
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
let Some(&first_idx) = lp.first() else { continue };
let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) {
@@ -453,8 +484,12 @@ impl VectorNetwork {
rebuilt.push(x);
let Some(s) = self.segments.get(x) else { continue };
cur = if s.a == cur { s.b } else { s.a };
if let Some(&arc) = by_vertex.get(&cur) {
rebuilt.push(arc);
if let Some((s1, arcs)) = by_vertex.get(&cur) {
if x == *s1 {
rebuilt.extend(arcs.iter().copied());
} else {
rebuilt.extend(arcs.iter().rev().copied());
}
}
}
*lp = rebuilt;
@@ -463,8 +498,9 @@ impl VectorNetwork {
}
/// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments
/// run on smoothly or fold back.
fn corner(&self, v: usize, incident: &[(usize, bool)]) -> Option<Corner> {
/// run on smoothly or fold back. With `smoothing`, a point between two straight segments that
/// are both `on_loop` gets a smoothed corner.
fn corner(&self, v: usize, incident: &[(usize, bool)], smoothing: f64, on_loop: &[bool]) -> Option<Corner> {
let r = self.vertices.get(v)?.radius;
let [(s1, a1), (s2, a2)] = *incident else { return None };
if !(r > 0.0 && r.is_finite()) || s1 == s2 {
@@ -480,6 +516,24 @@ impl VectorNetwork {
if !(l1 > 1e-9 && l2 > 1e-9) {
return None;
}
let own = |at_a: bool, t: f64| if at_a { t } else { 1.0 - t };
let closed = |s: usize| on_loop.get(s).copied().unwrap_or(false);
if smoothing > 0.0
&& is_line(&c1)
&& is_line(&c2)
&& closed(s1)
&& closed(s2)
&& let Some(curves) = figcraft_geom::smoothed_corner(c1.p0, u1, u2, l1.min(l2) / 2.0, r, smoothing)
&& let (Some(first), Some(last)) = (curves.first(), curves.last())
{
let (a, b) = (first.p0, last.p3);
let chain = curves
.iter()
.map(|q| CornerCurve { ta: (q.p1.x - q.p0.x, q.p1.y - q.p0.y), tb: (q.p2.x - q.p3.x, q.p2.y - q.p3.y), end: q.p3 })
.collect();
let (cut1, cut2) = ((a - c1.p0).hypot() / l1, (b - c2.p0).hypot() / l2);
return Some(Corner { v, s1, s1_at_a: a1, s2, s2_at_a: a2, a, chain, cut1: own(a1, cut1), cut2: own(a2, cut2) });
}
let t = (r / (angle / 2.0).tan()).min(l1.min(l2) / 2.0);
let ((a, d1, k1), (b, d2, k2)) = (trim(&c1, t, l1), trim(&c2, t, l2));
// A cubic arc: handles of 4/3·tan(φ/4)·ρ along each end's direction, φ the turn between
@@ -490,7 +544,6 @@ impl VectorNetwork {
if !(k.is_finite() && a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) {
return None;
}
let own = |at_a: bool, t: f64| if at_a { t } else { 1.0 - t };
Some(Corner {
v,
s1,
@@ -498,9 +551,7 @@ impl VectorNetwork {
s2,
s2_at_a: a2,
a,
b,
ta: (d1.x * k, d1.y * k),
tb: (d2.x * k, d2.y * k),
chain: vec![CornerCurve { ta: (d1.x * k, d1.y * k), tb: (d2.x * k, d2.y * k), end: b }],
cut1: own(a1, k1),
cut2: own(a2, k2),
})
+2 -2
View File
@@ -134,8 +134,8 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
}
match (&n.kind, part.region) {
(NodeKind::Vector { network }, Some(i)) => {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
let region = net.regions.get(i)?;
let path = net.region_path(i)?;
(path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding })
+8
View File
@@ -201,6 +201,7 @@ impl ToolId {
| ToolId::Star
| ToolId::Line
| ToolId::Arrow
| ToolId::BoardShape
)
}
}
@@ -591,6 +592,13 @@ pub struct ToolState {
pub picked: Option<figcraft_color::Color>,
/// The widget layers whose hover styles show while the pointer is over them (a live preview).
pub(crate) widget_hover: Vec<NodeId>,
/// The placement crosshair: where Enter puts the next shape or frame once the arrows have
/// shown it (in the middle of the view) and moved it (`tool.crosshair`), in page coordinates
/// of the document and page in `place_on` (elsewhere it isn't shown: `Session::crosshair`).
/// Placing, Escape and a change of tool take it away.
pub place_at: Option<Point>,
/// The document (uid) and page the crosshair was shown on.
pub(crate) place_on: Option<(u64, NodeId)>,
}
impl ToolState {
+2
View File
@@ -212,6 +212,8 @@ impl Session {
crate::normalize(ds.doc_mut(), &before);
}
self.commit_interaction()?;
// A placement by the pointer takes the placement crosshair with it, as Enter does.
self.tool.place_at = None;
if !self.prefs.keep_tool {
self.set_tool(ToolId::Move);
}
+26 -3
View File
@@ -84,9 +84,14 @@ impl Session {
if self.tool.busy() {
return refuse("finish the drag first");
}
// The crosshair the arrows moved goes with the placement, which lands on it when no point
// is given.
let crosshair = self.crosshair();
self.tool.place_at = None;
let v = &self.ds()?.view;
let (c, zoom) = (at.unwrap_or(Point::new(v.cx, v.cy)), v.zoom);
let (w, h) = tool.click_size();
let (c, zoom) = (at.or(crosshair).unwrap_or(Point::new(v.cx, v.cy)), v.zoom);
// The board Shapes tool centres its own shape on the press; the others get half a click's size.
let (w, h) = if matches!(tool, ToolId::BoardShape) { (0.0, 0.0) } else { tool.click_size() };
let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom);
let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up)));
if r.is_err() {
@@ -111,6 +116,16 @@ impl Session {
self.tool.overlay = Overlay::default();
return self.cancel_interaction();
}
// The placement crosshair goes first; the tool stays. One left on another page or
// document goes quietly.
let shown = self.crosshair().is_some();
self.tool.place_at = None;
if shown {
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
return Ok(());
}
if self.end_origin_edit() {
return Ok(());
}
@@ -174,6 +189,14 @@ impl Session {
/// Snapshot of tool state for the UI / inspect.
pub fn tool_info(&self) -> serde_json::Value {
let shapes: Vec<serde_json::Value> = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect();
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes})
let crosshair = self.crosshair().map(|p| [p.x, p.y]);
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes, "crosshair": crosshair})
}
/// The placement crosshair, while the document and page it was shown on are the ones in
/// front (`ToolState::place_at`).
pub fn crosshair(&self) -> Option<Point> {
let here = self.ds().ok().map(|d| (d.uid, d.page));
self.tool.place_at.filter(|_| here.is_some() && here == self.tool.place_on)
}
}
@@ -76,6 +76,10 @@ impl Session {
if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
let _ = crate::vectoredit::end(self);
}
// The placement crosshair goes with a change of tool, not with the same tool chosen again.
if self.tool.tool != t {
self.tool.place_at = None;
}
self.tool.tool = t;
self.tool.gesture = Gesture::None;
self.tool.pick_only = false;
@@ -56,12 +56,10 @@ fn region_filled(n: &figcraft_doc::Node, net: &VectorNetwork, i: usize) -> bool
net.regions.get(i).is_some_and(|r| r.fills.as_deref().unwrap_or(&n.fills).iter().any(|f| f.visible))
}
/// The network as its fill draws it: rounded points drawn in (region indexes are kept).
fn drawn(net: &VectorNetwork) -> std::borrow::Cow<'_, VectorNetwork> {
match net.rounded() {
Some(r) => std::borrow::Cow::Owned(r),
None => std::borrow::Cow::Borrowed(net),
}
/// The network as its fill draws it: rounded points drawn in with the layer's corner `smoothing`
/// (region indexes are kept).
fn drawn(net: &VectorNetwork, smoothing: f64) -> std::borrow::Cow<'_, VectorNetwork> {
figcraft_doc::geometry::drawn_network(net, smoothing)
}
/// The region of the active layer under page point `p` at `zoom`: none when a point is within
@@ -90,7 +88,7 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) {
return None;
}
let drawn = drawn(net);
let drawn = drawn(net, node.corners.smoothing);
(0..drawn.regions.len()).rev().find(|i| region_filled(node, &drawn, *i) && drawn.region_contains(*i, lp))
}
@@ -221,7 +219,7 @@ pub fn region_hatch(doc: &Document, ve: &VectorEdit, regions: &[usize], view: Af
let zoom = view.determinant().abs().sqrt();
let Some(spacing) = hatch_spacing(zoom).filter(|_| !regions.is_empty()) else { return Vec::new() };
let Some(net) = network(doc, ve.id) else { return Vec::new() };
let net = drawn(net);
let net = drawn(net, doc.node(ve.id).map_or(0.0, |n| n.corners.smoothing));
let m = view * doc.world_transform(ve.id);
let anchor = m * Point::ZERO;
let mut out = Vec::new();
+9 -4
View File
@@ -777,8 +777,11 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
for i in req_targets("vector.simplify", s, p)? {
let doc = s.doc_mut()?;
let n = doc.get_mut(i)?;
let smoothing = n.corners.smoothing;
if let NodeKind::Vector { network } = &mut n.kind {
let source = network.clone();
// The regions as the layer draws them: rounded points with the layer's corner smoothing.
let drawn = figcraft_doc::geometry::drawn_network(&source, smoothing);
let mut net = VectorNetwork::default();
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
if legacy && !source.regions.is_empty() {
@@ -787,7 +790,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
} else {
WindingRule::NonZero
};
let path = source.fill_path();
let path = drawn.fill_path();
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
Ok(path) => (path, WindingRule::EvenOdd),
@@ -797,7 +800,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
append_region_path(&mut net, &path, &combined, output_winding);
} else {
for (index, region) in source.regions.iter().enumerate() {
let Some(path) = source.region_path(index) else { continue };
let Some(path) = drawn.region_path(index) else { continue };
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
append_region_path(&mut net, &path, region, region.winding);
@@ -885,8 +888,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
nm.height = bb.height();
continue;
}
// The regions as the layer draws them: rounded points with the layer's corner smoothing.
let drawn = figcraft_doc::geometry::drawn_network(source, n.corners.smoothing);
if legacy {
let path = source.fill_path();
let path = drawn.fill_path();
let even_odd = source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd);
let path = figcraft_pathops::simplify_with_rule(&path, even_odd).map_err(|error| bad("vector.offset", error.to_string()))?;
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
@@ -906,7 +911,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
append_network(&mut net, part);
} else {
for (index, region) in source.regions.iter().enumerate() {
let Some(path) = source.region_path(index) else { continue };
let Some(path) = drawn.region_path(index) else { continue };
let mut paint_region = region.clone();
let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height);
paint_region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
+36
View File
@@ -529,6 +529,42 @@ pub fn specs() -> Vec<CommandSpec> {
tool(s, t)
}
),
spec!(
"tool.crosshair",
"Move the placement crosshair",
"{dx?, dy? (arrow-key steps, e.g. 1 or -1), big? (the large nudge, like Shift)} — the arrows with a shape or frame tool chosen and nothing selected: the first shows the crosshair in the middle of the view, the next move it by the nudge amount; Enter (`tool.place`) puts the object there",
false,
|s, p| {
if !s.tool.tool.draws_shape() {
return Err(EngineError::Disabled("tool.crosshair".into(), "choose a shape or frame tool first".into()));
}
let step = if boolean(p, "big").unwrap_or(false) { s.prefs.nudge_big } else { s.prefs.nudge_small };
let dir = |k| num(p, k).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-100.0, 100.0) * step;
let (dx, dy) = (dir("dx"), dir("dy"));
let (uid, page, centre, half) = {
let ds = s.ds()?;
let z = ds.view.zoom.max(1e-6);
(ds.uid, ds.page, figcraft_geom::Point::new(ds.view.cx, ds.view.cy), (ds.view.vw / z / 2.0, ds.view.vh / z / 2.0))
};
// "It will appear in the middle of the viewport": the first arrow shows it there (a
// crosshair left on another page or document starts over).
let at = match s.crosshair() {
Some(c) => figcraft_geom::Point::new(c.x + dx, c.y + dy),
None => centre,
};
s.tool.place_at = Some(at);
s.tool.place_on = Some((uid, page));
if let Ok(ds) = s.ds_mut() {
// "Move the crosshair around the canvas": nudged past an edge of the view, the
// view pans by the overshoot to keep it in sight.
let v = &mut ds.view;
v.cx += (at.x - (v.cx + half.0)).max(0.0) + (at.x - (v.cx - half.0)).min(0.0);
v.cy += (at.y - (v.cy + half.1)).max(0.0) + (at.y - (v.cy - half.1)).min(0.0);
ds.revision += 1;
}
Ok(json!({"x": at.x, "y": at.y}))
}
),
spec!(
"tool.place",
"Place with the tool",
+1
View File
@@ -354,6 +354,7 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
(Tools, "Pan with nothing selected (Shift: farther)", "← ↑ → ↓", "← ↑ → ↓"),
(Transform, "Rotate a degree (Shift: 15)", "⌥⌘ ← ↑ → ↓", "Ctrl+Alt+← ↑ → ↓"),
(Tools, "Place a shape or frame (its tool chosen)", "↵", "Enter"),
(Tools, "Show and move the placement crosshair (its tool chosen, nothing selected)", "← ↑ → ↓", "← ↑ → ↓"),
(Zoom, "Zoom tool: click to zoom in, drag an area", "Hold Z", "Hold Z"),
(Zoom, "Zoom tool: zoom out", "Hold ⌥Z", "Hold Alt+Z"),
(Zoom, "Zoom around the pointer", "⌘ scroll", "Ctrl+scroll"),
+2
View File
@@ -469,6 +469,8 @@ mod penpot_export_tests;
#[cfg(test)]
mod pixel_preview_tests;
#[cfg(test)]
mod place_crosshair_tests;
#[cfg(test)]
mod placed_image_size_tests;
#[cfg(test)]
mod playground_tests;
+122
View File
@@ -0,0 +1,122 @@
//! The placement crosshair: the arrows with a shape or frame tool chosen show it in the middle of
//! the view and move it, and Enter places the object there ("Use the arrow keys to activate the
//! crosshair cursor. It will appear in the middle of the viewport indicating where the new object
//! will be placed." / "Use the arrow keys to move the crosshair around the canvas or board, then
//! press Return/Enter to place the object.", Accessibility at Figma, #1839).
use serde_json::json;
use crate::Session;
use crate::tools::{PointerEvent, PointerKind};
#[test]
fn the_arrows_show_and_move_the_crosshair_and_enter_places_there() {
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
assert!(s.execute("tool.crosshair", &json!({"dx": 1})).is_err(), "a shape or frame tool first");
s.execute("tool.rectangle", &json!({})).unwrap();
// The first arrow shows the crosshair in the middle of the view, unmoved; the next move it by
// the nudge amounts.
let r = s.execute("tool.crosshair", &json!({"dx": 1, "dy": 0})).unwrap();
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(300.0), Some(200.0)));
s.execute("tool.crosshair", &json!({"dx": 1, "dy": 0})).unwrap();
let r = s.execute("tool.crosshair", &json!({"dx": 0, "dy": -1, "big": true})).unwrap();
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(301.0), Some(190.0)), "a nudge, then a big nudge");
assert_eq!(s.tool_info()["crosshair"], json!([301.0, 190.0]));
let v = &s.ds().unwrap().view;
assert_eq!((v.cx, v.cy), (300.0, 200.0), "the view hasn't panned");
// Enter: the rectangle is centred on the crosshair, which goes.
let placed = s.execute("tool.place", &json!({})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
let b = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((b.center().x, b.center().y), (301.0, 190.0));
assert_eq!(s.tool.place_at, None);
assert!(s.tool_info()["crosshair"].is_null());
// Escape takes a shown crosshair away before anything else, the tool staying; a tool change
// takes it away too.
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
assert!(s.tool.place_at.is_some());
s.escape().unwrap();
assert_eq!((s.tool.place_at, s.tool.tool), (None, crate::ToolId::Rectangle));
s.execute("tool.crosshair", &json!({})).unwrap();
s.execute("tool.ellipse", &json!({})).unwrap();
assert_eq!(s.tool.place_at, None);
}
#[test]
fn the_crosshair_keeps_its_page_and_goes_with_a_pointer_placement_but_not_the_same_tool_again() {
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
// The tool already chosen named again keeps the crosshair: `tool.place {tool}` lands on it.
let placed = s.execute("tool.place", &json!({"tool": "rectangle"})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(id).unwrap().center().x, 301.0);
// A click places too (the tool kept by the preference) and takes the crosshair with it.
s.prefs.keep_tool = true;
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 600.0, 600.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 600.0, 600.0, 1.0)).unwrap();
assert_eq!((s.tool.tool, s.tool.place_at), (crate::ToolId::Rectangle, None), "placed by the pointer: the crosshair goes, the tool stays");
// Another page in front: the crosshair shown on the first isn't there, Escape has nothing to
// take, and the next arrow starts over in the middle of this page's view.
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 5})).unwrap();
s.execute("page.add", &json!({})).unwrap();
s.execute("page.select", &json!({"index": 1})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
assert_eq!(s.crosshair(), None);
assert!(s.tool_info()["crosshair"].is_null());
s.escape().unwrap();
assert_eq!(s.tool.tool, crate::ToolId::Move, "Escape went on to the tool");
s.execute("tool.rectangle", &json!({})).unwrap();
let r = s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
let v = &s.ds().unwrap().view;
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(v.cx), Some(v.cy)), "shown afresh in the middle of this page's view");
}
#[test]
fn the_view_pans_to_keep_the_crosshair_in_sight() {
// "Use the arrow keys to move the crosshair around the canvas or board": nudged past an edge
// of the view, the view follows it by the overshoot; inside it, the view stays put.
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 10.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
for _ in 0..6 {
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
}
let v = s.ds().unwrap().view.clone();
assert_eq!((s.tool.place_at.map(|p| p.x), v.cx, v.cy), (Some(360.0), 300.0, 200.0), "at the edge of the 120 px wide view: no pan yet");
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
let v = s.ds().unwrap().view.clone();
assert_eq!((v.cx, v.cy), (310.0, 200.0), "ten past the edge: the view moved ten");
for _ in 0..5 {
s.execute("tool.crosshair", &json!({"dy": -1, "big": true})).unwrap();
}
let v = s.ds().unwrap().view.clone();
assert_eq!((v.cx, v.cy), (310.0, 190.0), "the view is 80 px tall: 40 up is its edge, 50 moves it ten");
}
#[test]
fn the_board_shapes_tool_places_on_the_crosshair_too() {
// "...move the crosshair around the canvas or board, then press Return/Enter to place the
// object": the board Shapes tool takes the crosshair like the shape tools, its shape centred
// on it (the tool centres its own 160 px shape on a press).
let mut s = crate::board_tests::board();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.set", &json!({"tool": "shape"})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 2})).unwrap();
let placed = s.execute("tool.place", &json!({})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
let b = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((b.center().x, b.center().y, b.width(), b.height()), (302.0, 200.0, 160.0, 160.0));
assert_eq!(s.tool.place_at, None);
}
+50 -1
View File
@@ -233,7 +233,14 @@ fn corner_smoothing_follows_the_documented_rules() {
(NodeKind::Frame, true, "frames"),
(NodeKind::Polygon { count: 5 }, true, "polygons"),
(NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, "stars"),
(NodeKind::Vector { network: Default::default() }, false, "documented for closed vectors; their points round without it here"),
(
NodeKind::Vector {
network: figcraft_doc::VectorNetwork::from_points(&[Point::ZERO, Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true),
},
true,
"vector networks that are closed shapes",
),
(NodeKind::Vector { network: Default::default() }, false, "an open vector has no closed shape to smooth"),
(NodeKind::Line, false, "lines take no radius, so no smoothing"),
];
for (kind, smoothing, why) in doc_rows {
@@ -241,6 +248,48 @@ fn corner_smoothing_follows_the_documented_rules() {
}
}
#[test]
fn closed_vectors_take_corner_smoothing() {
// "Vector networks that are closed shapes" take corner smoothing, which applies to the entire
// shape; a single open stroke does not.
let mut s = Session::new();
let closed = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}));
let open = create(&mut s, "vector.create", json!({"points": [[200, 0], [300, 0], [300, 100]], "closed": false}));
ex(&mut s, "node.set", json!({"ids": [closed, open], "props": {"cornerRadius": 20}}));
let outline = |s: &Session, id: NodeId| figcraft_doc::geometry::shape_path(&node(s, id)).unwrap();
let svg = |s: &Session, id: NodeId| figcraft_svg::export_svg(s.doc().unwrap(), &[id], &figcraft_svg::SvgOptions::default()).unwrap();
let render = |s: &Session, id: NodeId| figcraft_render::node_outline(s.doc().unwrap(), &node(s, id)).unwrap().0;
let (round, open_round, svg_round, drawn_round) = (outline(&s, closed), outline(&s, open), svg(&s, closed), render(&s, closed));
ex(&mut s, "node.set", json!({"ids": [closed, open], "props": {"cornerSmoothing": 0.6}}));
let smooth = outline(&s, closed);
// The smoothed corner reaches 1.6 radii along each edge, as a smoothed rectangle's does.
let want = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6).area().abs();
assert!((smooth.area().abs() - want).abs() < 0.1, "the closed vector smooths: {} vs {want}", smooth.area().abs());
assert!((smooth.area().abs() - round.area().abs()).abs() > 1.0, "smoothing changes the closed vector's outline");
assert_ne!(svg(&s, closed), svg_round, "SVG export draws the smoothed corners");
assert_ne!(render(&s, closed), drawn_round, "the canvas draws the smoothed corners");
assert_eq!(outline(&s, open), open_round, "an open vector keeps its plain rounded points");
let stroke = figcraft_doc::geometry::stroke_geometry(&node(&s, closed)).unwrap();
assert!((stroke.area().abs() - want).abs() < 0.1, "the stroke follows the smoothed outline");
// Smoothing needs a radius, and undo restores the plain corners in one step.
s.undo().unwrap();
assert_eq!(outline(&s, closed), round, "one undo step");
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerRadius": 0}}));
let sharp = outline(&s, closed);
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerSmoothing": 0.6}}));
assert_eq!(outline(&s, closed), sharp, "smoothing needs a radius");
// A vector whose region has paints of its own draws the smoothed corners too, on the canvas
// and in SVG.
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerRadius": 20}}));
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(closed).unwrap().kind {
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
}
let (region_svg, region_path) = (svg(&s, closed), render(&s, closed));
assert!((region_path.area().abs() - want).abs() < 0.5, "region paints follow the smoothed outline: {}", region_path.area().abs());
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerSmoothing": 0}}));
assert_ne!(svg(&s, closed), region_svg, "SVG export draws the region's smoothed corners");
}
#[test]
fn rounded_vector_points_are_drawn() {
// A converted 100 × 100 square: a point's radius rounds that corner only (it wasn't drawn).
@@ -278,6 +278,45 @@ fn legacy_evenodd_holes_survive_bucket_offset_and_flatten() {
assert!(!net(&s, flattened).fill_contains(Point::new(100.0, 100.0)), "flatten retains the even-odd hole");
}
#[test]
fn simplify_and_offset_keep_a_closed_vectors_smoothed_corners() {
// A closed 100 × 100 vector with radius 20 on every point and corner smoothing 0.6 draws a
// smoothed rectangle's outline; simplify and offset rebuild it from that outline.
let drawn = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6);
let plain = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.0).area().abs();
let (area, d) = (drawn.area().abs(), 2.0);
// Offsetting a convex outline by d adds its perimeter times d and a circle of radius d.
let grown = area + drawn.perimeter(1e-6) * d + std::f64::consts::PI * d * d;
assert!((area - plain).abs() > 10.0, "smoothing changes the outline: {area} vs {plain}");
// (command, params, region paints of its own, expected area, tolerance: the offset ring is
// flattened before the union)
let rows = [
("vector.simplify", json!({"tolerance": 0.01}), false, area, 1.0),
("vector.simplify", json!({"tolerance": 0.01}), true, area, 1.0),
("vector.offset", json!({"offset": d}), false, grown, 4.0),
("vector.offset", json!({"offset": d}), true, grown, 4.0),
];
let mut wrong = Vec::new();
for (cmd, mut params, painted, expected, tolerance) in rows {
let mut s = Session::new();
let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerRadius": 20}}));
ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerSmoothing": 0.6}}));
if painted {
let doc = s.doc_mut().unwrap();
let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
}
params["ids"] = json!([id]);
ex(&mut s, cmd, params);
let got = net(&s, id).fill_path().area().abs();
if (got - expected).abs() >= tolerance {
wrong.push(format!("{cmd} (region paints: {painted}): {got} vs {expected}"));
}
}
assert!(wrong.is_empty(), "simplify and offset keep the smoothed corners: {wrong:?}");
}
/// Give vector `id` a painted region, converting a plain shape to a vector first.
fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) {
let n = doc.get(id).unwrap();
+2 -2
View File
@@ -34,10 +34,10 @@ pub(super) fn outlined(n: &Node) -> Option<(Node, String)> {
}
let path = figcraft_doc::geometry::stroke_geometry(n)?;
let net = match &n.kind {
NodeKind::Vector { network } => Some(network),
NodeKind::Vector { network } => Some(figcraft_doc::geometry::drawn_network(network, n.corners.smoothing)),
_ => None,
};
let area = figcraft_pathops::stroke_outline_of(&path, sp, figcraft_doc::geometry::is_closed(n), net);
let area = figcraft_pathops::stroke_outline_of(&path, sp, figcraft_doc::geometry::is_closed(n), net.as_deref());
if area.elements().is_empty() || area.elements().len() > MAX_NETWORK / 4 {
return None;
}
+2 -2
View File
@@ -270,7 +270,7 @@ impl<'d> Exporter<'d> {
if net.regions.iter().any(|r| r.winding != winding) {
self.warn("vectors whose filled areas use different winding rules use the first one's in their outline");
}
self.geometry("fillGeometry", &net.fill_path(), winding, f);
self.geometry("fillGeometry", &figcraft_doc::geometry::drawn_network(net, n.corners.smoothing).fill_path(), winding, f);
}
}
@@ -322,7 +322,7 @@ impl<'d> Exporter<'d> {
self.geometry("strokeGeometry", &ring, WindingRule::EvenOdd, f);
} else if sp.align == StrokeAlign::Center {
let path = match &n.kind {
NodeKind::Vector { network } => Some(network.stroke_path()),
NodeKind::Vector { .. } => figcraft_doc::geometry::stroke_geometry(n),
NodeKind::Text(_) => None,
_ => figcraft_doc::geometry::shape_path(n),
};
+43
View File
@@ -912,6 +912,49 @@ fn written_files_decode_and_list_parents_first() {
}
}
#[test]
fn closed_vector_geometry_is_written_with_its_corner_smoothing() {
// A closed vector with rounded points: its written fill and stroke outlines take the layer's
// corner smoothing, and the smoothing and each point's radius come back on import.
let mut doc = Document::new("smoothing");
let page = doc.first_page().unwrap();
let mut network = VectorNetwork::from_points(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y)), true);
for (v, r) in network.vertices.iter_mut().zip([20.0, 0.0, 10.0, 0.0]) {
v.radius = r;
}
let id = doc.create(page, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
doc.node_mut(id).unwrap().strokes = vec![Paint::solid(Color::BLACK)];
// The vector's written fill and stroke outlines.
let outlines = |doc: &Document| -> Vec<Vec<u8>> {
let (bytes, _) = export(doc, None).unwrap();
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let blobs = msg.get("blobs").unwrap().list();
let changes = msg.get("nodeChanges").unwrap().list();
let nc = changes.iter().find(|nc| nc.get("type").and_then(Kv::str) == Some("VECTOR")).unwrap();
["fillGeometry", "strokeGeometry"]
.iter()
.map(|key| {
let paths = nc.get(key).map(Kv::list).unwrap_or_default();
let blob = paths.first().unwrap_or_else(|| panic!("{key} is written")).get("commandsBlob").unwrap().f64().unwrap() as usize;
blobs[blob].get("bytes").and_then(Kv::bytes).unwrap().to_vec()
})
.collect()
};
let plain = outlines(&doc);
doc.node_mut(id).unwrap().corners.smoothing = 0.6;
let smoothed = outlines(&doc);
assert_ne!(smoothed[0], plain[0], "the fill outline takes the smoothing");
assert_ne!(smoothed[1], plain[1], "the stroke outline takes the smoothing");
let (back, _) = round_trip(&doc, "smoothed vector");
let v = back.nodes().find(|n| matches!(n.kind, NodeKind::Vector { .. })).unwrap();
assert!((v.corners.smoothing - 0.6).abs() < 1e-6, "smoothing comes back: {}", v.corners.smoothing);
let NodeKind::Vector { network } = &v.kind else { panic!("vector") };
let radii: Vec<f64> = network.vertices.iter().map(|x| x.radius).collect();
assert_eq!(radii, vec![20.0, 0.0, 10.0, 0.0], "each point keeps its own radius");
}
#[test]
fn the_written_schema_parses_and_round_trips() {
let schema = Schema::parse(export::SCHEMA).unwrap();
+2 -2
View File
@@ -403,10 +403,10 @@ fn the_schema_numbers_what_the_format_numbers() {
#[test]
fn schema_text_takes_numbers_and_refuses_bad_ones() {
let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=2").unwrap();
let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=5").unwrap();
let values = |i: usize| s.defs[i].fields.iter().map(|f| (f.name.as_str(), f.value)).collect::<Vec<_>>();
assert_eq!(values(0), [("A", 3), ("B", 1), ("C", 9)]);
assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 2)]);
assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 5)]);
for bad in ["enum E A=", "enum E A=x", "enum E A=-1", "message M uint a=1.5", "message M uint a=99999999999", "enum E =", "message M uint ="] {
assert!(Schema::parse(bad).is_err() || bad.ends_with('='), "{bad}");
}
+6
View File
@@ -276,6 +276,9 @@ impl Schema {
let ty = r.var_int()?;
let array = r.byte()? & 1 != 0;
let value = r.var_uint()?;
if kind == Kind::Message && fields.iter().any(|f: &Field| f.value == value) {
return err(format!("schema: duplicate field id {value} in {name}"));
}
raw_types.push(ty);
fields.push(Field { name: fname, ty: None, array, value });
}
@@ -645,6 +648,9 @@ impl Schema {
}
.ok_or_else(|| KiwiError(format!("schema text: unknown type {base}")))?;
let (name, value) = numbered(n.trim(), id)?;
if *kind == "message" && fields.iter().any(|f: &Field| f.value == value) {
return err(format!("schema text: duplicate field id {value} in {name}"));
}
fields.push(Field { name, ty: Some(ty), array: t.ends_with("[]"), value });
}
(if *kind == "struct" { Kind::Struct } else { Kind::Message }, fields)
+5
View File
@@ -562,6 +562,11 @@ fn oversized_grid_track_lists_warn_and_use_default_tracks() {
assert!(warnings.iter().any(|w| w.contains("grid track list exceeds 1000 entries")), "{warnings:?}");
}
#[test]
fn duplicate_explicit_field_ids_are_ambiguous() {
assert!(Schema::parse("message M uint a=1, uint b=1").is_err());
}
#[test]
fn kiwi_round_trip() {
let sc = schema();
+3 -1
View File
@@ -713,7 +713,9 @@ impl<'a> Exporter<'a> {
}
}
}
NodeKind::Vector { network } if node.fills.is_empty() || !node.strokes.is_empty() => self.doc.world_transform(id) * network.stroke_path(),
NodeKind::Vector { network } if node.fills.is_empty() || !node.strokes.is_empty() => {
self.doc.world_transform(id) * figcraft_doc::geometry::drawn_network(network, node.corners.smoothing).stroke_path()
}
_ => match figcraft_doc::geometry::shape_path(node) {
Some(p) => self.doc.world_transform(id) * p,
None => BezPath::new(),
+26
View File
@@ -174,6 +174,32 @@ fn rectangles_ellipses_and_paths_keep_their_geometry() {
assert!(matches!(back.node(named(&back, "Circle")).unwrap().kind, NodeKind::Ellipse { .. }));
}
#[test]
fn stroked_closed_vectors_are_written_with_their_corner_smoothing() {
// A stroked closed 100 × 100 vector with radius 20 on every point and corner smoothing 0.6 is
// written as the smoothed outline it draws.
let mut doc = Document::new("Smoothed vector");
let p = page(&doc);
let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)];
let mut network = VectorNetwork::from_points(&corners, true);
for v in &mut network.vertices {
v.radius = 20.0;
}
let id = add(&mut doc, p, NodeKind::Vector { network }, "Smoothed", 0.0, 0.0, 100.0, 100.0);
{
let n = doc.node_mut(id).unwrap();
n.fills = vec![Paint::solid(Color::BLACK)];
n.strokes = vec![Paint::solid(Color::BLACK)];
n.corners.smoothing = 0.6;
}
let want = figcraft_doc::geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6).area().abs();
let plain = figcraft_doc::geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.0).area().abs();
assert!((want - plain).abs() > 10.0, "smoothing changes the outline: {want} vs {plain}");
let (back, _, _) = roundtrip(&doc);
let written = figcraft_doc::geometry::shape_path(back.node(named(&back, "Smoothed")).unwrap()).unwrap().area().abs();
assert!((written - want).abs() < 0.5, "the written path keeps the smoothed corners: {written} vs {want}");
}
#[test]
fn fills_and_strokes_keep_their_order_and_styles() {
let mut doc = Document::new("Paints");
+9
View File
@@ -14,6 +14,9 @@ use super::tree::{Cx, color};
const MAX_CHARS: usize = 1 << 20;
/// Most runs read from one text layer.
const MAX_RUNS: usize = 10_000;
/// Most characters that styled runs may write in all, overlaps counted again (a run's range is
/// rewritten in full, even where later runs overwrite it).
const MAX_RUN_WORK: usize = 16 * MAX_CHARS;
/// Fill `node` (a TEXT node) from text layer `l`.
pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap<String, Value>) {
@@ -80,10 +83,16 @@ pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap<String, Value>) {
// Runs that differ from the first: a style id per UTF-16 unit.
let mut ids = vec![0u64; units];
let mut table = JsonMap::new();
let mut work = 0usize;
for (start, end, s) in &styles {
if *s == base {
continue;
}
work = work.saturating_add(end.saturating_sub(*start));
if work > MAX_RUN_WORK {
cx.warn("text whose styled runs overlap a great deal keeps the first runs' styles");
break;
}
let id = table.len() as u64 + 1;
table.insert(id.to_string(), s.clone());
for slot in ids.get_mut(*start..*end).into_iter().flatten() {
+10
View File
@@ -271,3 +271,13 @@ fn later_runs_dont_inherit_the_first_runs_settings() {
assert_eq!(second.line_height, figcraft_doc::LineHeight::Auto, "{second:?}");
assert!(second.variations.is_empty(), "{second:?}");
}
#[test]
fn runs_that_overlap_a_great_deal_stop_being_applied() {
// 17 runs over the whole of a text of 2^20 characters: 17 × 2^20 writes without the limit.
let n = 1 << 20;
let mut runs = vec![(0, 1, "Helvetica", 10.0, BLACK)];
runs.extend((0..17).map(|i| (0, n, "Helvetica", 11.0 + f64::from(i), BLACK)));
let (_, warnings) = open(vec![text("Long", 0.0, 0.0, 200.0, 20.0, &"a".repeat(n), &runs)]);
assert!(warnings.iter().any(|w| w.contains("overlap a great deal")), "{warnings:?}");
}
+117 -51
View File
@@ -249,67 +249,133 @@ pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPat
};
let (v1, v2) = (*prev - *cur, *next - *cur);
let (l1, l2) = (v1.hypot(), v2.hypot());
let angle = if l1 < 1e-9 || l2 < 1e-9 { 0.0 } else { (v1 / l1).dot(v2 / l2).clamp(-1.0, 1.0).acos() };
if angle < 1e-6 || (PI - angle) < 1e-6 {
let corner = if l1 < 1e-9 || l2 < 1e-9 { None } else { smooth_corner(*cur, v1 / l1, v2 / l2, l1.min(l2) / 2.0, radius, s) };
let Some(k) = corner else {
line_or_move(&mut path, *cur);
continue;
};
line_or_move(&mut path, k.start);
match k.ease_in {
Some((h1, h2)) => path.curve_to(h1, h2, k.p3),
None => path.line_to(k.p3),
}
let (u1, u2) = (v1 / l1, v2 / l2);
// The plain corner's length along each edge, then the fit: smoothing first, then the radius.
let max_p = l1.min(l2) / 2.0;
let mut r = radius;
let mut t = r / (angle / 2.0).tan();
let mut sm = s;
if (1.0 + sm) * t > max_p {
sm = (max_p / t - 1.0).clamp(0.0, sm);
if k.sweep.abs() > 1e-9 {
kurbo::Arc::new(k.center, (k.r, k.r), k.a0, k.sweep, 0.0).to_cubic_beziers(0.01, |p1, p2, q| path.curve_to(p1, p2, q));
}
if t > max_p {
r *= max_p / t;
t = max_p;
}
let p = ((1.0 + sm) * t).min(max_p);
// Each easing curve covers `beta` of the turn and ends on the arc at `back` along the edge
// and `side` across it; its last handle points along the arc there.
let beta = (PI - angle) * sm / 2.0;
let back = t - r * beta.sin();
let side = r * (1.0 - beta.cos());
let c = r * (beta / 2.0).tan() * beta.cos();
let b = ((p - back - c) / 3.0).max(0.0);
let a = 2.0 * b;
let bisector = (u1 + u2).normalize();
let center = *cur + bisector * (r / (angle / 2.0).sin());
// A point `along` an edge from the vertex and `across` it toward the arc's center.
let at = |u: Vec2, along: f64, across: f64| *cur + u * along + (bisector - u * bisector.dot(u)).normalize() * across;
line_or_move(&mut path, at(u1, p, 0.0));
let p3 = at(u1, back, side);
let p4 = at(u2, back, side);
if a + b + c > 1e-9 {
path.curve_to(at(u1, p - a, 0.0), at(u1, p - a - b, 0.0), p3);
} else {
path.line_to(p3);
}
let a0 = (p3 - center).atan2();
let mut sweep = (p4 - center).atan2() - a0;
while sweep <= -PI {
sweep += TAU;
}
while sweep > PI {
sweep -= TAU;
}
if sweep.abs() > 1e-9 {
kurbo::Arc::new(center, (r, r), a0, sweep, 0.0).to_cubic_beziers(0.01, |p1, p2, q| path.curve_to(p1, p2, q));
}
let end = at(u2, p, 0.0);
if a + b + c > 1e-9 {
path.curve_to(at(u2, p - a - b, 0.0), at(u2, p - a, 0.0), end);
} else {
path.line_to(end);
match k.ease_out {
Some((h1, h2)) => path.curve_to(h1, h2, k.end),
None => path.line_to(k.end),
}
}
path.close_path();
path
}
/// One smoothed corner as [`smoothed_polygon`] draws it: it starts at `start` on the first edge,
/// eases into a circular arc at `p3` (through handles `ease_in`, or straight when `None`), follows
/// the arc of radius `r` about `center` from angle `a0` through `sweep` to `p4`, and eases out to
/// `end` on the second edge.
struct SmoothCorner {
start: Point,
ease_in: Option<(Point, Point)>,
p3: Point,
center: Point,
r: f64,
a0: f64,
sweep: f64,
p4: Point,
ease_out: Option<(Point, Point)>,
end: Point,
}
/// The smoothed corner at `cur` between edges that leave it along unit directions `u1` and `u2`,
/// with `max_p` of room along each: `None` where the edges run straight on or fold back.
fn smooth_corner(cur: Point, u1: Vec2, u2: Vec2, max_p: f64, radius: f64, s: f64) -> Option<SmoothCorner> {
let angle = u1.dot(u2).clamp(-1.0, 1.0).acos();
if angle < 1e-6 || (PI - angle) < 1e-6 {
return None;
}
// The plain corner's length along each edge, then the fit: smoothing first, then the radius.
let mut r = radius;
let mut t = r / (angle / 2.0).tan();
let mut sm = s;
if (1.0 + sm) * t > max_p {
sm = (max_p / t - 1.0).clamp(0.0, sm);
}
if t > max_p {
r *= max_p / t;
t = max_p;
}
let p = ((1.0 + sm) * t).min(max_p);
// Each easing curve covers `beta` of the turn and ends on the arc at `back` along the edge
// and `side` across it; its last handle points along the arc there.
let beta = (PI - angle) * sm / 2.0;
let back = t - r * beta.sin();
let side = r * (1.0 - beta.cos());
let c = r * (beta / 2.0).tan() * beta.cos();
let b = ((p - back - c) / 3.0).max(0.0);
let a = 2.0 * b;
let bisector = (u1 + u2).normalize();
let center = cur + bisector * (r / (angle / 2.0).sin());
// A point `along` an edge from the vertex and `across` it toward the arc's center.
let at = |u: Vec2, along: f64, across: f64| cur + u * along + (bisector - u * bisector.dot(u)).normalize() * across;
let p3 = at(u1, back, side);
let p4 = at(u2, back, side);
let eased = a + b + c > 1e-9;
let a0 = (p3 - center).atan2();
let mut sweep = (p4 - center).atan2() - a0;
while sweep <= -PI {
sweep += TAU;
}
while sweep > PI {
sweep -= TAU;
}
Some(SmoothCorner {
start: at(u1, p, 0.0),
ease_in: eased.then(|| (at(u1, p - a, 0.0), at(u1, p - a - b, 0.0))),
p3,
center,
r,
a0,
sweep,
p4,
ease_out: eased.then(|| (at(u2, p - a - b, 0.0), at(u2, p - a, 0.0))),
end: at(u2, p, 0.0),
})
}
/// The curves of one corner rounded by `radius` with corner smoothing (0..=1) at `corner`, between
/// two straight edges that leave it along unit directions `u1` and `u2` with `max_p` of room along
/// each. The curves follow the profile of [`smoothed_polygon`], with the circular part as one
/// cubic, and run from the point on the `u1` edge to the point on the `u2` edge; a straight piece
/// has its handles on its ends. As in the polygon, smoothing is reduced first when the corner does
/// not fit, then the radius. `None` for a corner that is not rounded, or where the edges run
/// straight on or fold back.
pub fn smoothed_corner(corner: Point, u1: Vec2, u2: Vec2, max_p: f64, radius: f64, smoothing: f64) -> Option<Vec<kurbo::CubicBez>> {
let (s, radius, max_p) = (finite(smoothing).clamp(0.0, 1.0), finite(radius).max(0.0), finite(max_p).max(0.0));
let usable = |u: Vec2| u.x.is_finite() && u.y.is_finite() && u.hypot() > 1e-9;
if radius <= 0.0 || max_p <= 0.0 || !usable(u1) || !usable(u2) || !(corner.x.is_finite() && corner.y.is_finite()) {
return None;
}
let k = smooth_corner(corner, u1 / u1.hypot(), u2 / u2.hypot(), max_p, radius, s)?;
let piece = |p0: Point, handles: Option<(Point, Point)>, p3: Point| {
let (h1, h2) = handles.unwrap_or((p0, p3));
kurbo::CubicBez::new(p0, h1, h2, p3)
};
let mut out = vec![piece(k.start, k.ease_in, k.p3)];
if k.sweep.abs() > 1e-9 {
// The arc as one cubic: handles of 4/3·tan(θ/4)·r along the circle's tangents at its ends.
let h = 4.0 / 3.0 * (k.sweep.abs() / 4.0).tan() * k.r * k.sweep.signum();
let tangent = |a: f64| Vec2::new(-a.sin(), a.cos());
out.push(kurbo::CubicBez::new(k.p3, k.p3 + tangent(k.a0) * h, k.p4 - tangent(k.a0 + k.sweep) * h, k.p4));
}
out.push(piece(k.p4, k.ease_out, k.end));
out.retain(|q| (q.p3 - q.p0).hypot() > 1e-9 || (q.p1 - q.p0).hypot() > 1e-9 || (q.p2 - q.p3).hypot() > 1e-9);
let finite_point = |p: &Point| p.x.is_finite() && p.y.is_finite();
let ok = out.iter().all(|q| [q.p0, q.p1, q.p2, q.p3].iter().all(finite_point));
(ok && !out.is_empty()).then_some(out)
}
/// Starts `path` at `p`, or continues it there with a straight segment.
fn line_or_move(path: &mut BezPath, p: Point) {
if path.elements().is_empty() {
+47
View File
@@ -52,6 +52,53 @@ fn degenerate_inputs_dont_panic() {
let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), f64::INFINITY, f64::NAN);
let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), 3.0, f64::INFINITY);
let _ = ellipse(f64::NAN, 10.0, f64::NAN, 100.0, 2.0);
// (corner, first edge, second edge, room, radius, smoothing): none of these is a corner to round.
let x = Vec2::new(1.0, 0.0);
let rows = [
(Point::ZERO, x, Vec2::new(0.0, 1.0), 10.0, 0.0, 0.6),
(Point::ZERO, x, Vec2::new(0.0, 1.0), 0.0, 5.0, 0.6),
(Point::ZERO, x, x, 10.0, 5.0, 0.6),
(Point::ZERO, x, -x, 10.0, 5.0, 0.6),
(Point::ZERO, Vec2::ZERO, x, 10.0, 5.0, 0.6),
(Point::ZERO, Vec2::new(f64::NAN, 0.0), x, 10.0, 5.0, 0.6),
(Point::new(f64::INFINITY, 0.0), x, Vec2::new(0.0, 1.0), 10.0, 5.0, 0.6),
];
for (corner, u1, u2, room, r, s) in rows {
assert!(smoothed_corner(corner, u1, u2, room, r, s).is_none(), "{corner:?} {u1:?} {u2:?} {room} {r} {s}");
}
}
#[test]
fn smoothed_corner_draws_the_polygon_corner() {
use kurbo::ParamCurve;
// The corner of a square drawn by itself follows the smoothed polygon's corner and joins its
// edges and its own pieces without a kink.
for s in [0.0, 0.6, 1.0] {
let curves = smoothed_corner(Point::ZERO, Vec2::new(0.0, 1.0), Vec2::new(1.0, 0.0), 50.0, 20.0, s).unwrap();
let mut path = BezPath::new();
path.move_to(curves[0].p0);
for q in &curves {
path.curve_to(q.p1, q.p2, q.p3);
}
path.line_to((100.0, 0.0));
path.line_to((100.0, 100.0));
path.line_to((0.0, 100.0));
path.close_path();
let want = area(&rounded_rect(100.0, 100.0, [20.0, 0.0, 0.0, 0.0], s));
assert!((area(&path) - want).abs() < 0.05, "s={s}: {} vs {want}", area(&path));
assert!((curves[0].p0 - Point::new(0.0, 20.0 * (1.0 + s))).hypot() < 1e-9, "s={s}: it starts 1 + smoothing radii along the edge");
let last = curves[curves.len() - 1];
assert!((last.p3 - Point::new(20.0 * (1.0 + s), 0.0)).hypot() < 1e-9, "s={s}: and ends as far along the other");
let dir = |c: &kurbo::CubicBez, t: f64| {
let d = if t < 0.5 { c.eval(1e-6) - c.eval(0.0) } else { c.eval(1.0) - c.eval(1.0 - 1e-6) };
d.normalize()
};
assert!(dir(&curves[0], 0.0).dot(Vec2::new(0.0, -1.0)) > 0.999, "s={s}: it leaves along the first edge");
assert!(dir(&last, 1.0).dot(Vec2::new(1.0, 0.0)) > 0.999, "s={s}: it arrives along the second edge");
for pair in curves.windows(2) {
assert!(dir(&pair[0], 1.0).dot(dir(&pair[1], 0.0)) > 0.999, "s={s}: its pieces join smoothly");
}
}
}
#[test]
+2 -2
View File
@@ -338,8 +338,8 @@ impl<'a> Exporter<'a> {
if let NodeKind::Vector { network } = &n.kind
&& network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())
{
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
for (i, region) in net.regions.iter().enumerate() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
if let Some(mut region_path) = net.region_path(i) {
+21
View File
@@ -82,6 +82,27 @@ fn pdf_vector_region_paints_emit_independent_colors() {
assert!(content.contains("1 0 0 rg") && content.contains("0 0 1 rg"), "{content}");
}
#[test]
fn pdf_vector_region_paints_draw_smoothed_corners() {
// A closed vector with rounded points and paints on its region: corner smoothing changes the
// exported outline, as on the canvas.
let corners = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| figcraft_geom::Point::new(x, y));
let mut network = VectorNetwork::from_points(&corners, true);
for v in &mut network.vertices {
v.radius = 20.0;
}
network.regions[0].fills = Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]);
let mut d = Document::new("smoothing");
let p = d.first_page().unwrap();
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
let content = |d: &Document| inflated(&export_pdf(d, &[id], &PdfOptions::default()).unwrap());
let plain = content(&d);
d.get_mut(id).unwrap().corners.smoothing = 0.6;
let smoothed = content(&d);
assert!(smoothed.contains("1 0 0 rg"), "{smoothed}");
assert_ne!(smoothed, plain, "the region's outline takes the smoothing");
}
/// A page with two frames: shapes, gradients, an image, strokes, a mask, a boolean, effects, text.
pub(crate) fn sample_doc() -> (Document, Vec<NodeId>) {
let mut d = Document::new("sample");
+8 -6
View File
@@ -274,7 +274,9 @@ fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath>
if let Some(p) = locked(stroke_cache()).get(key) {
return p;
}
let out = Arc::new(figcraft_pathops::stroke_outline_of(path, props, closed, net));
// Points with styles of their own are stroked on the network as drawn (smoothed corners in).
let drawn = net.map(|network| figcraft_doc::geometry::drawn_network(network, n.corners.smoothing));
let out = Arc::new(figcraft_pathops::stroke_outline_of(path, props, closed, drawn.as_deref()));
locked(stroke_cache()).insert(key, out.clone());
out
}
@@ -334,9 +336,9 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
Some((p, FillRule::Winding))
}
NodeKind::Vector { network } => {
let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let mut areas = BezPath::new();
for (i, region) in net.regions.iter().enumerate() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
@@ -357,7 +359,7 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
}
let rule =
if network.regions.iter().any(|r| r.winding == figcraft_doc::WindingRule::EvenOdd) { FillRule::EvenOdd } else { FillRule::Winding };
Some((network.fill_path(), rule))
Some((net.fill_path(), rule))
}
_ => figcraft_doc::geometry::shape_path(n).map(|p| (p, FillRule::Winding)),
}
@@ -849,8 +851,8 @@ fn region_painted(n: &Node) -> Option<&figcraft_doc::VectorNetwork> {
/// Each region of a vector with region paints, filled with its own paints (or the layer's) in its
/// own paint frame.
fn fill_vector_regions(ctx: &Ctx, pix: &mut Pixmap, n: &Node, network: &figcraft_doc::VectorNetwork, xf: Affine) {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
for (i, region) in net.regions.iter().enumerate() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
if let Some(mut path) = net.region_path(i) {
+2 -2
View File
@@ -537,8 +537,8 @@ impl W<'_> {
if let NodeKind::Vector { network } = &n.kind
&& network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())
{
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
for (i, region) in net.regions.iter().enumerate() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
let fr = if region.winding == figcraft_doc::WindingRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
+9
View File
@@ -79,6 +79,15 @@ pub(crate) fn is_space(c: char) -> bool {
class(c) == Class::Space
}
/// Whether justified text may widen the gap between `a` and the `b` after it (a space widens on
/// its own): next to an ideograph, kana, Hangul or full-width form, which are written without
/// spaces, so each is a word of its own, as for line breaking, wherever a line may break there (no
/// gap before a closing mark or after an opening one).
pub(crate) fn justify_between(a: char, b: char) -> bool {
let (ca, cb) = (class(a), class(b));
(ca == Class::Ideo || cb == Class::Ideo) && ca != Class::Space && break_between(a, b)
}
/// Whether `c` is in a script written without spaces between words that the line segmenter has
/// word data for (UAX #14 class SA): Thai, Lao, Burmese (with the Myanmar extensions) and Khmer.
fn writes_no_spaces(c: char) -> bool {
+54 -11
View File
@@ -315,6 +315,37 @@ pub(crate) fn is_rtl_char(c: char) -> bool {
matches!(c as u32, 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF | 0x10800..=0x10FFF | 0x1E800..=0x1EFFF | 0x200F | 0x202B | 0x202E | 0x2067)
}
/// Which visual positions of a line's items (in `visual_order(levels)`) a justify gap follows. The
/// gap after logical item k (`after[k]`) is reordered like a glyph of no width at the lower of its
/// two neighbours' levels, as rule N1 resolves a space between them: between logically neighbouring
/// glyphs it opens where they are, and where runs meet it opens beside the run it belongs to, so a
/// gap beside a Hebrew word in Chinese text opens beside the word, not inside it, and so does one
/// beside a Chinese word within that Hebrew. From item `trailing` on (a line's trailing spaces) a
/// gap opens after the item itself.
fn visual_gaps(levels: &[u8], after: &[bool], trailing: usize) -> Vec<bool> {
let gap_after = |k: usize| after.get(k).copied().unwrap_or(false);
let level = |k: usize| levels.get(k).copied().unwrap_or(0);
// Items at the even indices, the gap after item k at 2k + 1 (a gap's level never exceeds its
// neighbours', so every run reversed starts and ends on an item and the two keep alternating).
let mut both = Vec::with_capacity(after.len() * 2);
for k in 0..after.len() {
if k > 0 {
both.push(level(k).min(level(k - 1)));
}
both.push(level(k));
}
let order = visual_order(&both);
(0..after.len())
.map(|i| {
let k = order.get(2 * i).copied().unwrap_or(0) / 2;
if k >= trailing {
return gap_after(k);
}
order.get(2 * i + 1).is_some_and(|&g| g % 2 == 1 && g / 2 < trailing && gap_after(g / 2))
})
.collect()
}
/// Visual order of a line's items from their levels (rule L2: from the highest level down to the
/// lowest odd one, reverse every run at that level or above).
pub(crate) fn visual_order(levels: &[u8]) -> Vec<usize> {
@@ -1290,8 +1321,20 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let hang_r =
items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv);
let gstart = out.glyphs.len();
// Justify: extra space per gap.
let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count();
// Justify: extra space per gap (a space, or between ideographs, whose text has no spaces;
// never inside a grapheme cluster, so a mark stays on its base).
let widens = |k: usize| {
items.get(k).is_some_and(|it| {
it.stretches()
|| (k + 1 < vis_end
&& items.get(k + 1).is_some_and(|next| {
next.char_start > it.char_start
&& cluster_start.get(next.char_start).copied().unwrap_or(true)
&& crate::breaks::justify_between(it.ch, next.ch)
}))
})
};
let gaps = (*s..vis_end).filter(|k| widens(*k)).count();
let justify_extra = match (text.align_h, wrap) {
(TextAlignH::Justified, Some(maxw)) if !para_end && !line_break_after(&items, *e) && gaps > 0 => {
((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0)
@@ -1373,20 +1416,20 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
// Right-to-left runs are placed in visual order; trailing spaces take the paragraph's
// direction (rule L1).
let line_items = items.get(*s..*e).unwrap_or(&[]);
let order: Vec<usize> = if bidi.is_some() {
let para_level = u8::from(rtl_line);
let levels: Vec<u8> = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect();
visual_order(&levels)
} else {
(0..line_items.len()).collect()
};
for it in order.iter().filter_map(|k| line_items.get(*k)) {
let para_level = u8::from(rtl_line);
let levels: Vec<u8> = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect();
let order = visual_order(&levels);
// Each gap opens at its visual boundary; trailing spaces widen where they are.
let after: Vec<bool> = (0..line_items.len()).map(|k| widens(*s + k)).collect();
let gaps_at = visual_gaps(&levels, &after, vis_end - *s);
for (i, &k) in order.iter().enumerate() {
let Some(it) = line_items.get(k) else { continue };
if is_line_break(it.ch) {
continue;
}
out.glyphs.extend(glyph_at(&styles, it, pen, baseline));
pen += it.adv;
if it.stretches() {
if gaps_at.get(i).copied().unwrap_or(false) {
pen += justify_extra;
}
}
+55
View File
@@ -618,6 +618,61 @@ fn lower_case_writes_final_sigma() {
assert_eq!(gids(&t).iter().map(|g| g.0).collect::<Vec<_>>(), want.iter().map(|g| g.0).collect::<Vec<_>>(), "split runs");
}
/// Chinese is written without spaces: justified, the room goes between its characters (each a word
/// of its own, as for line breaking), so the last one reaches the right edge too, and none opens
/// where a line couldn't break (before a closing mark, after an opening one). "Justify aligns the
/// first word in each line of text to the left-most edge, and the last to the right-most edge."
#[test]
fn justified_ideographs_spread_the_room_between_characters() {
if !have_fonts() {
return;
}
let lay_out = |s: &str, align, w| {
let mut t = TextData::new(s);
t.style.font_size = 20.0;
t.align_h = align;
t.auto_resize = figcraft_doc::TextAutoResize::Height;
layout(&t, w, None)
};
// Box width cut inside char `fit`, so the first line is chars 0..fit; how many gaps lie before
// each of its chars.
let rows: [(&str, usize, &[i32], &str); 6] = [
("一二三四五六七八九十", 6, &[0, 1, 2, 3, 4, 5], "one gap between each pair of ideographs"),
("一二三。四五六七八九", 6, &[0, 1, 2, 2, 3, 4], "none before the full stop, which never starts a line"),
("一二三「四五六七八九", 6, &[0, 1, 2, 3, 3, 4], "none after the opening bracket, which never ends a line"),
("一二三\u{20DD}四五六七八九", 6, &[0, 1, 2, 2, 3, 4], "none inside a grapheme cluster: a combining mark stays on its base"),
("漢אב字一二三", 4, &[0, 1, 1, 2], "beside a right-to-left word (laid out ב then א), not inside it"),
(
"漢\u{202B}אב字一גד\u{202C}文一二三",
10,
&[0, 1, 4, 4, 2, 3, 1, 1, 1, 5],
"beside a Chinese word embedded in a Hebrew phrase embedded in Chinese (laid out 漢 ד ג 字 一 ב א 文, the embedding marks of no width), and beside the phrase",
),
];
for (s, fit, gaps_before, why) in rows {
let natural = lay_out(s, TextAlignH::Left, None);
let (fits, next) = (natural.caret(fit).0, natural.caret(fit + 1).0);
assert!(next > fits, "{why}: chars need advances");
let w = fits + (next - fits) / 4.0;
let left = lay_out(s, TextAlignH::Left, Some(w));
let just = lay_out(s, TextAlignH::Justified, Some(w));
assert!(just.lines.len() >= 2 && just.lines[0].char_end == fit, "{why}: {:?}", just.lines);
let extra = (w - left.lines[0].width) / f64::from(gaps_before[fit - 1]);
assert_eq!(left.lines[0].char_end, fit, "{why}: {:?}", left.lines);
assert!(extra > 0.0, "{why}");
assert_eq!(left.glyphs.len(), just.glyphs.len());
for (l, j) in left.glyphs.iter().zip(&just.glyphs).filter(|(g, _)| g.char_end <= fit) {
let want = f64::from(gaps_before[l.char_start]) * extra;
assert!((j.x - l.x - want).abs() < 1e-6, "{why}: char {} moved {} (want {want})", l.char_start, j.x - l.x);
}
let last = just.glyphs.iter().rfind(|g| g.char_end <= fit).unwrap();
assert!((last.x + last.advance - w).abs() < 1e-6, "{why}: the last char ends at the right edge, not {}", last.x + last.advance);
// The last line is left-aligned, as always.
let (l2, j2) = (left.glyphs.iter().find(|g| g.char_start == fit).unwrap(), just.glyphs.iter().find(|g| g.char_start == fit).unwrap());
assert!((j2.x - l2.x).abs() < 1e-6, "{why}: the last line is not justified");
}
}
/// Thai is written without spaces between words; U+200B ZERO WIDTH SPACE marks where a line may
/// break. ภาษา|ไทย|เขียน|ติดกัน ("Thai is written joined up") ไม่มี|ช่องว่าง|ระหว่าง|คำ ("with no
/// spaces between words"), with one real space between the two phrases.
+1
View File
@@ -1095,6 +1095,7 @@ name = "English"
"Pages: {pages}" = "Pages: {pages}"
"Paint bucket" = "Paint bucket"
"Pan the canvas" = "Pan the canvas"
"Show and move the placement crosshair (its tool chosen, nothing selected)" = "Show and move the placement crosshair (its tool chosen, nothing selected)"
"Pan with nothing selected (Shift: farther)" = "Pan with nothing selected (Shift: farther)"
"Panels" = "Panels"
"Paper" = "Paper"
+1
View File
@@ -2369,6 +2369,7 @@ status = "draft — needs native review"
"tool.arrow" = "Flecha"
"tool.comment" = "Comentario"
"tool.connector" = "Conector"
"tool.crosshair" = "Mover la cruz de colocación"
"tool.ellipse" = "Elipse"
"tool.eraser" = "Borrador"
"tool.frame" = "Marco"
+43
View File
@@ -1952,6 +1952,39 @@ status = "draft — needs native review"
"⌘ click" = "⌘ click"
"⌘ scroll" = "⌘ scroll"
"⌥ drag" = "⌥ drag"
"1 year" = "1 vuosi"
"30 days" = "30 päivää"
"90 days" = "90 päivää"
"Wrap style" = "Rivitystyyli"
"Balance" = "Tasattu"
"Pretty" = "Siisti"
"Auto: as many words as fit on each line" = "Automaattinen: jokaiselle riville niin monta sanaa kuin mahtuu"
"Balance: every line close to the same length" = "Tasattu: kaikki rivit lähes samanpituisia"
"Pretty: full lines, no one word alone on the last" = "Siisti: täydet rivit, viimeiselle riville ei jää yksittäistä sanaa"
"Set hug contents" = "Aseta Sovita sisältöön"
"Set fill container" = "Aseta Täytä säilö"
"Double-click an edge of an auto layout frame" = "Kaksoisnapsauta automaattisen asettelun kehyksen reunaa"
"⌥ double-click an edge of an auto layout child" = "⌥-kaksoisnapsauta automaattisen asettelun alikohteen reunaa"
"Alt+double-click an edge of an auto layout child" = "Alt+kaksoisnapsauta automaattisen asettelun alikohteen reunaa"
"Drop to use this image" = "Käytä tätä kuvaa pudottamalla"
"Expand the interface" = "Laajenna käyttöliittymä"
"Hide UI" = "Piilota käyttöliittymä"
"Past 30 days" = "Viimeiset 30 päivää"
"Past 90 days" = "Viimeiset 90 päivää"
"Past year" = "Viimeinen vuosi"
"Nothing counted yet" = "Mitään ei ole vielä laskettu"
"Rotate a degree (Shift: 15)" = "Kierrä asteen verran (Shift: 15)"
"⌥⌘ ← ↑ → ↓" = "⌥⌘ ← ↑ → ↓"
"Ctrl+Alt+← ↑ → ↓" = "Ctrl+Alt+← ↑ → ↓"
"Remove all auto layout" = "Poista kaikki automaattinen asettelu"
"This server doesn't count insertions" = "Tämä palvelin ei laske lisäyksiä"
"This server doesn't count styles and variables" = "Tämä palvelin ei laske tyylejä ja muuttujia"
"Upload from computer" = "Lataa tietokoneelta"
"The bars are hidden. {shortcut} shows them again" = "Palkit on piilotettu. {shortcut} näyttää ne uudelleen"
"{insertions} · {detachments}" = "{insertions} · {detachments}"
"{n} inserted" = { one = "{n} lisätty", other = "{n} lisätty" }
"{period}: {counts}" = "{period}: {counts}"
"{used} · {inserted}" = "{used} · {inserted}"
[commands]
"align.bottom" = "Tasaa alas"
@@ -2585,6 +2618,16 @@ status = "draft — needs native review"
"vector.fillRule" = "Aseta täyttösääntö"
"vector.reverseLoop" = "Käännä silmukka"
"widget.hover" = "Pienoisohjelman kohdistus"
"board.reshape" = "Muotoile yhdistin uudelleen"
"image.replace" = "Korvaa kuva"
"layout.removeAll" = "Poista kaikki automaattinen asettelu"
"pref.uiScaleDown" = "Pienempi käyttöliittymä"
"pref.uiScaleReset" = "Käyttöliittymän oletuskoko"
"pref.uiScaleUp" = "Suurempi käyttöliittymä"
"text.openLink" = "Avaa linkki"
"text.wrapStyle" = "Rivitystyyli"
"variable.extendCollection" = "Laajenna kokoelma"
"view.minimize" = "Pienennä käyttöliittymä"
[menu]
"bool.exclude" = "Jätä pois"
+1
View File
@@ -37,6 +37,7 @@ impl FigApp {
field_focus: false,
panels_unsaved: false,
control_queue: Vec::new(),
control_waits: Default::default(),
control_input: Vec::new(),
frame_stats: frame_stats::FrameStats::default(),
windows: windows::Windows::default(),
+71 -17
View File
@@ -48,25 +48,79 @@ pub fn single_line(ui: &mut Ui, id: Id, text: &mut String, font: FontId, width:
.response
}
/// One letter per script the UI's own fonts may lack: Hebrew, Arabic, Ethiopic, Devanagari,
/// Gurmukhi, Gujarati, Tamil, Telugu, Kannada, Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian
/// and Georgian. egui's shaper selects the joined contextual forms itself. Bengali and Thai are
/// not here: [`crate::cjk`] picks their faces, in its own order of preference, after this list.
pub(crate) const SCRIPT_SAMPLES: [char; 16] = [
'\u{05D0}', '\u{0628}', '\u{1200}', '\u{0915}', '\u{0A15}', '\u{0A95}', '\u{0B95}', '\u{0C15}', '\u{0C95}', '\u{0D15}', '\u{0D9A}', '\u{0E81}',
'\u{1000}', '\u{1780}', '\u{0531}', '\u{10D0}',
];
/// System families that draw those scripts, in order of preference: the first installed one that
/// has a script's letter is taken, so a family covering several scripts (Segoe UI, Nirmala UI,
/// Kohinoor, Noto Sans) joins once. Windows, macOS and the common Linux fonts for each script.
pub(crate) const SCRIPT_FAMILIES: [&str; 46] = [
// Hebrew and Arabic.
"Segoe UI",
"Arial",
"Tahoma",
"Noto Sans Hebrew",
"Noto Sans Arabic",
"Arial Hebrew",
"Geeza Pro",
"Lucida Grande",
"DejaVu Sans",
"FreeSans",
"Noto Sans",
// Ethiopic (#2014).
"Ebrima",
"Nyala",
"Kefa",
"Noto Sans Ethiopic",
"Abyssinica SIL",
// Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada, Malayalam and Sinhala.
"Nirmala UI",
"Kohinoor Devanagari",
"Kohinoor Gujarati",
"Kohinoor Telugu",
"Gurmukhi MN",
"Tamil Sangam MN",
"Kannada Sangam MN",
"Malayalam Sangam MN",
"Sinhala Sangam MN",
"Noto Sans Devanagari",
"Noto Sans Gurmukhi",
"Noto Sans Gujarati",
"Noto Sans Tamil",
"Noto Sans Telugu",
"Noto Sans Kannada",
"Noto Sans Malayalam",
"Noto Sans Sinhala",
"Lohit Devanagari",
// Lao, Myanmar and Khmer.
"Leelawadee UI",
"Lao Sangam MN",
"Myanmar Text",
"Myanmar Sangam MN",
"Khmer Sangam MN",
"Noto Sans Lao",
"Noto Sans Myanmar",
"Noto Sans Khmer",
// Armenian and Georgian.
"Sylfaen",
"Mshtakan",
"Noto Sans Armenian",
"Noto Sans Georgian",
];
/// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data;
/// `taken` as for [`crate::theme::system_fallbacks`].
/// `taken` as for [`crate::theme::system_fallbacks`]. Hebrew and Arabic names were covered first;
/// Ethiopic and the other scripts in [`SCRIPT_SAMPLES`] drew as boxes in the panels (a String
/// variable's Amharic value, #2014) until the system face for each was taken too. Bengali and
/// Thai keep [`crate::cjk`]'s choice, made after this list.
pub fn script_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec<Arc<figcraft_text::Face>>) -> Vec<(String, egui::FontData)> {
// Hebrew and Arabic letters; egui's shaper selects the joined contextual forms itself.
const SAMPLES: [char; 2] = ['\u{05D0}', '\u{0628}'];
const FAMILIES: [&str; 11] = [
"Segoe UI",
"Arial",
"Tahoma",
"Noto Sans Hebrew",
"Noto Sans Arabic",
"Arial Hebrew",
"Geeza Pro",
"Lucida Grande",
"DejaVu Sans",
"FreeSans",
"Noto Sans",
];
crate::theme::system_fallbacks(SAMPLES, &FAMILIES, have, taken)
crate::theme::system_fallbacks(SCRIPT_SAMPLES, &SCRIPT_FAMILIES, have, taken)
}
#[cfg(test)]
+49
View File
@@ -310,3 +310,52 @@ fn clicks_place_the_logical_caret_by_what_is_shown() {
run(vec![Event::Text("א".into())], &mut text, &mut rect);
assert_eq!(text, "שלוםא");
}
/// Scripts beyond Hebrew and Arabic (#2014): a String variable's Amharic value drew as boxes
/// because the UI took system faces for two scripts only. Now every script in `SCRIPT_SAMPLES`
/// with an installed face gets one, and the UI's fonts then have its glyphs. A machine without a
/// face for a script skips that script (CI has few fonts); a face the UI's own fonts cover is not
/// taken twice.
#[test]
fn other_scripts_take_an_installed_system_face() {
figcraft_text::scan_system_fonts();
let installed = |c: char| figcraft_text::face_with_char(c, &crate::bidi::SCRIPT_FAMILIES).is_some();
let mut taken = Vec::new();
let added = crate::bidi::script_fallbacks(&|_| false, &mut taken);
let scripts = [('\u{1200}', "Ethiopic"), ('\u{0915}', "Devanagari"), ('\u{0B95}', "Tamil"), ('\u{1000}', "Myanmar"), ('\u{0531}', "Armenian")];
let mut checked = 0;
for (c, script) in scripts {
if !installed(c) {
eprintln!("no {script} face on this system; skipping");
continue;
}
let families: Vec<&str> = taken.iter().map(|f| f.family.as_str()).collect();
assert!(taken.iter().any(|f| f.has_char(c)), "a face for {script} is taken: {families:?}");
checked += 1;
}
// A face that several scripts share is added once, under its system key.
let keys: Vec<&str> = added.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys.len(), taken.len(), "{keys:?}");
assert!(keys.iter().all(|k| k.starts_with("system-")), "{keys:?}");
let mut seen = keys.clone();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), keys.len(), "no face twice: {keys:?}");
// Nothing is taken for a script the UI's own fonts draw.
assert!(crate::bidi::script_fallbacks(&|_| true, &mut Vec::new()).is_empty());
if checked == 0 {
eprintln!("no face for any of the sampled scripts on this system; the UI font check was not run");
return;
}
// Through the whole font setup: the UI draws the letters of every script it found a face for.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, true, 0);
ctx.run_ui(RawInput::default(), |_| {}).textures_delta.clear();
ctx.fonts_mut(|f| {
for (c, script) in scripts {
if installed(c) {
assert!(f.has_glyph(&regular(11.0), c), "the UI fonts draw {script} ({c:?})");
}
}
});
}
+37 -17
View File
@@ -25,6 +25,14 @@ impl HoverKey {
}
}
/// Whether a button that pans the canvas is held: the middle button, or a mouse's side buttons
/// (4 and 5), which the incumbent treats the same way (#1758). Space + primary pans too, handled
/// with the Space key.
fn pan_button_down(i: &egui::InputState) -> bool {
use egui::PointerButton::{Extra1, Extra2, Middle};
[Middle, Extra1, Extra2].into_iter().any(|b| i.pointer.button_down(b))
}
/// The pointer left the canvas (or something over it took the pointer): nothing is hovered there.
fn left(app: &mut FigApp) {
app.canvas.last_hover = None;
@@ -140,6 +148,31 @@ fn import_dropped(app: &mut FigApp, ctx: &egui::Context, hover_pos: Option<egui:
}
}
/// One frame of a pan (the middle or a side button held, or Space + drag): the view follows the
/// pointer from where the pan began, and the pan ends once no button that pans is held.
fn pan_view(app: &mut FigApp, ctx: &egui::Context, pos: Pos2, mods: &egui::Modifiers, middle_down: bool) {
if let Ok(ds) = app.session.ds_mut() {
match app.canvas.middle_pan {
None => app.canvas.middle_pan = Some((pos, ds.view.cx, ds.view.cy)),
Some((p0, cx, cy)) => {
let z = ds.view.zoom.max(1e-6);
ds.view.cx = cx - f64::from(pos.x - p0.x) / z;
ds.view.cy = cy - f64::from(pos.y - p0.y) / z;
ds.revision += 1;
}
}
}
// A primary drag under way when the pan began ends when its button is let go, pan or
// not: the engine gets its release here, as it does over the canvas or off it.
if app.canvas.dragging && !ctx.input(|i| i.pointer.primary_down()) {
app.canvas.dragging = false;
send(app, PointerKind::Up, pos, mods);
}
if !middle_down && !ctx.input(|i| i.pointer.primary_down()) {
app.canvas.middle_pan = None;
}
}
/// The pointer and keys on the canvas. False when something on it with a cursor of its own has
/// the pointer (a comment pin, a ruler guide, a gradient or padding handle): the tool's cursor
/// then doesn't show.
@@ -180,7 +213,7 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
left(app);
}
// A pan whose button was released off the canvas is over.
if !ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle) || i.pointer.primary_down()) {
if !ctx.input(|i| pan_button_down(i) || i.pointer.primary_down()) {
app.canvas.middle_pan = None;
}
// So is a handle drag.
@@ -213,23 +246,10 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
if zoom_tool(app, ui, resp, pos, mods, editing_text || typing_elsewhere) {
return true;
}
// Middle-button / space panning.
let middle_down = ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle));
// Middle-button (or side-button) / space panning.
let middle_down = ctx.input(pan_button_down);
if (middle_down || (app.canvas.space_pan && ctx.input(|i| i.pointer.primary_down()))) && resp.hovered() || app.canvas.middle_pan.is_some() {
if let Ok(ds) = app.session.ds_mut() {
match app.canvas.middle_pan {
None => app.canvas.middle_pan = Some((pos, ds.view.cx, ds.view.cy)),
Some((p0, cx, cy)) => {
let z = ds.view.zoom.max(1e-6);
ds.view.cx = cx - f64::from(pos.x - p0.x) / z;
ds.view.cy = cy - f64::from(pos.y - p0.y) / z;
ds.revision += 1;
}
}
}
if !middle_down && !ctx.input(|i| i.pointer.primary_down()) {
app.canvas.middle_pan = None;
}
pan_view(app, &ctx, pos, &mods, middle_down);
return true;
}
// A handle drag whose button went up ends first, wherever on the canvas the pointer is: over a
+13
View File
@@ -145,6 +145,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
}
// Pen path.
pen_overlay(app, painter, zoom);
crosshair_overlay(app, painter);
// Eraser brush (vector edit mode).
if let Some([x, y, r]) = ov.brush {
let c = app.to_screen(x, y);
@@ -416,6 +417,18 @@ fn attach_points(app: &FigApp, painter: &egui::Painter, points: &[([f64; 2], boo
}
}
/// The placement crosshair the arrows showed and moved: where Enter puts the next shape or frame.
fn crosshair_overlay(app: &FigApp, painter: &egui::Painter) {
let Some(p) = app.session.crosshair().filter(|_| app.session.tool.tool.draws_shape()) else { return };
let c = app.to_screen(p.x, p.y);
let col = accent(painter);
for d in [vec2(1.0, 0.0), vec2(0.0, 1.0)] {
painter.line_segment([c - d * 12.0, c - d * 3.0], stroke(col, 1.5));
painter.line_segment([c + d * 3.0, c + d * 12.0], stroke(col, 1.5));
}
painter.circle_stroke(c, 3.0, stroke(col, 1.5));
}
fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) {
let ov = &app.session.tool.overlay;
if !ov.pen.is_empty() {
+33 -1
View File
@@ -454,6 +454,24 @@ fn hidden(ctx: &Context, id: u64) -> bool {
visibility(ctx, id) == Some(false)
}
/// How long, in seconds, a window may draw no frame while a control request waits for it before
/// [`background`] answers the request as it answers one for a hidden window. eframe runs the app's
/// logic for a window whose redraw is 0.25 s overdue, so the second such pass answers.
pub const NOT_DRAWING_AFTER: f64 = 0.2;
/// Whether window `id` has drawn no frame for [`NOT_DRAWING_AFTER`] while a request waited for it.
/// The platform may still call it visible: under Wayland an app can't tell that its window is
/// minimised, and the compositor then stops asking it to draw. The wait starts again whenever the
/// window draws.
fn stopped_drawing(app: &mut FigApp, ctx: &Context, id: u64, now: f64) -> bool {
let pass = ctx.cumulative_pass_nr_for(crate::windows::viewport(id));
let wait = app.control_waits.entry(id).or_insert((pass, now));
if wait.0 != pass {
*wait = (pass, now);
}
now - wait.1 >= NOT_DRAWING_AFTER
}
/// Send `RequestPaintWhileHidden` for work that needs a real UI pass (a queued capture or
/// synthetic input), and schedule a wakeup for the next capture deadline. Native eframe honours
/// this command; the shared web runner does not implement it yet, so a hidden browser tab remains
@@ -511,7 +529,7 @@ pub fn background(app: &mut FigApp, ctx: &Context) {
let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) });
continue;
}
let is_hidden = hidden(ctx, target);
let is_hidden = hidden(ctx, target) || stopped_drawing(app, ctx, target, now);
if r.method == "ui.screenshot" && visibility(ctx, target) != Some(true) {
// Hidden, minimised, or with visibility the platform has not reported: admit it now
// so the request gets its deadline and a forced paint. Waiting for a frame that may
@@ -564,6 +582,14 @@ pub fn background(app: &mut FigApp, ctx: &Context) {
}
let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) });
}
// A window a request still waits for is checked again soon: if it draws, its frame answers;
// if it doesn't (minimised under Wayland), the next logic pass does.
let waiting: std::collections::BTreeSet<u64> =
app.control_queue.iter().map(|r| r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused)).collect();
app.control_waits.retain(|id, _| waiting.contains(id));
if !waiting.is_empty() {
crate::repaint_in(ctx, NOT_DRAWING_AFTER);
}
request_paint(app, ctx);
}
@@ -1004,6 +1030,12 @@ fn ui_key(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<Value, String> {
"ArrowUp" => (0, -1),
_ => (0, 1),
};
// A shape or frame tool chosen and nothing selected: the arrows show and move the
// placement crosshair, as the window's key path does.
if app.session.selection().is_empty() && app.session.tool.tool.draws_shape() && !app.session.tool.dev_mode && !(m.cmd || m.ctrl || m.alt)
{
return app.session.execute("tool.crosshair", &json!({"dx": dx, "dy": dy, "big": m.shift})).map_err(|e| e.to_string());
}
let (cmd, params) = if (m.cmd || m.ctrl) && m.alt {
// Rotate a degree, 15 with Shift: down and right clockwise, up and left back, about
// the selection's rotation origin; nothing while points are being edited.
@@ -184,3 +184,36 @@ fn polygons_and_stars_take_corner_smoothing() {
assert_eq!(u.app.session.undo_depth().0, history + 1, "{cmd}: one undo step");
}
}
/// A closed vector shows the same Corner smoothing control, and its iOS button sets 60% in one
/// undo step; an open vector, which has no closed shape to smooth, shows no smoothing control.
#[test]
fn closed_vectors_take_corner_smoothing() {
let rows = [(true, json!([[0, 0], [120, 0], [120, 120], [0, 120]])), (false, json!([[0, 0], [120, 0], [120, 120]]))];
for (closed, points) in rows {
let mut s = Session::new();
let id = NodeId(s.execute("vector.create", &json!({"points": points, "closed": closed})).unwrap()["id"].as_u64().unwrap());
s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
u.frame(vec![]);
let out = u.frame_output(vec![]);
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
let toggle = nodes.iter().any(|(_, n)| n.role() == Role::Button && n.label() == Some("Corner smoothing"));
assert_eq!(toggle, closed, "closed {closed}: the Corner smoothing button shows only for a closed vector");
if !closed {
continue;
}
let t = named_button(&mut u, "Corner smoothing");
click(&mut u, t);
assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "no field per corner");
assert!(u.ctx.read_response(Id::unique("smoothing").with("field")).is_some(), "the smoothing field shows");
let history = u.app.session.undo_depth().0;
let ios = named_button(&mut u, "iOS");
click(&mut u, ios);
let n = u.app.session.doc().unwrap().node(id).unwrap().clone();
assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "iOS sets 60%: {}", n.corners.smoothing);
assert_eq!(u.app.session.undo_depth().0, history + 1, "one undo step");
}
}
+47
View File
@@ -434,6 +434,53 @@ fn a_hidden_window_answers_at_once() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #1906: under Wayland a minimised window still reports itself visible (no `minimized`, no
/// `occluded`) and draws no frames; eframe runs only its logic. A request waiting for that
/// window's frame is answered from the logic passes once the window has drawn nothing for
/// [`crate::control::NOT_DRAWING_AFTER`], instead of timing out after 60 s. A window that draws
/// keeps answering from its frame.
#[test]
fn a_window_that_stopped_drawing_answers_from_its_logic_passes() {
let (mut h, text) = document();
h.settle();
h.time += 120.0;
h.app.clock.set_test_now(Some(h.time));
let (tx, rx) = std::sync::mpsc::channel();
let rename = |id: u64, name: &str| ControlRequest {
id: json!(id),
method: "engine.execute".into(),
params: json!({"command": "node.set", "params": {"ids": [text.clone()], "props": {"name": name}}}),
reply: tx.clone(),
state: Default::default(),
};
let node = figcraft_doc::NodeId(text.as_u64().unwrap());
let name = |h: &Harness| h.app.session.doc().unwrap().node(node).unwrap().name.clone();
// What Wayland reports for a minimised window: nothing about its visibility.
let unknown = RawInput::default();
let ctx = h.ctx.clone();
h.app.control_queue.push(rename(1, "while minimised"));
let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx));
assert!(rx.try_recv().is_err(), "a window that may still draw answers from its frame");
assert_eq!(h.app.control_queue.len(), 1);
// No frame comes: the next logic pass answers.
h.app.clock.set_test_now(Some(h.time + crate::control::NOT_DRAWING_AFTER + 0.05));
let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx));
let reply = rx.try_recv().expect("answered while the window draws nothing");
assert_eq!(reply.id, json!(1));
assert!(reply.result.is_ok(), "{:?}", reply.result);
assert_eq!(name(&h), "while minimised");
assert!(h.app.control_queue.is_empty(), "nothing waits for the window");
// Back on screen: a frame drawn after the wait began starts it again, and the frame answers.
h.time += 1.0;
h.app.clock.set_test_now(Some(h.time));
h.app.control_queue.push(rename(2, "on screen"));
let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx));
h.app.clock.set_test_now(Some(h.time + crate::control::NOT_DRAWING_AFTER + 0.05));
h.frame(vec![]);
let reply = rx.try_recv().expect("the frame answers");
assert_eq!((reply.id, name(&h)), (json!(2), "on screen".to_string()));
}
#[test]
fn auto_save_writes_a_changed_file_then_rests() {
let dir = std::env::temp_dir().join(format!("figcraft-auto-save-{}", std::process::id()));
@@ -411,6 +411,81 @@ fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree()
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
}
/// A press of `key` in the frame that's next, then a few frames to settle.
fn press_page_field_key(u: &mut Ui, key: egui::Key) {
u.frame(vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
for _ in 0..5 {
u.frame(vec![]);
}
}
/// Asserts a click on the name of page `page`, away from page `other`, opens it and starts no
/// rename: the pages list is back to plain rows however the last rename ended.
fn assert_clicking_page_opens_it(u: &mut Ui, page: NodeId, other: NodeId, row: Pos2) {
assert!(u.app.ui.rename_page.is_none(), "the rename is over");
u.app.session.execute("page.select", &json!({"id": other})).unwrap();
for _ in 0..30 {
u.frame(vec![Event::PointerMoved(row)]);
}
u.button(row, true);
u.button(row, false);
for _ in 0..5 {
u.frame(vec![]);
}
assert_eq!(u.app.session.page().unwrap(), page, "the click opens the page");
assert!(!u.ctx.memory(|m| m.has_focus(Id::unique(("pagename", page)).with("te"))), "and doesn't edit its name");
}
/// Asserts a page's row is a plain row after a rename that was cancelled, committed unchanged or
/// committed with a new name: a click on its name opens the page rather than editing the name.
#[test]
fn a_finished_page_rename_leaves_a_row_that_a_click_opens() {
for (key, text) in [(egui::Key::Escape, None), (egui::Key::Enter, None), (egui::Key::Enter, Some("Renamed"))] {
let mut s = Session::new();
let p1 = s.page().unwrap();
let p2 = id(s.execute("page.add", &json!({})).unwrap());
let mut u = Ui::new(s);
let r2 = page_row(&mut u, p2, p1);
u.ctx.enable_accesskit();
double_click_checking_focus(&mut u, r2);
assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p2), "a double click renames the page");
if let Some(text) = text {
u.frame(vec![Event::Text(text.into())]);
}
press_page_field_key(&mut u, key);
assert_clicking_page_opens_it(&mut u, p2, p1, r2);
}
}
/// Asserts the row's Rename page puts the keyboard in the name, and a name typed there is kept.
#[test]
fn the_page_menus_rename_starts_with_the_keyboard_in_the_name() {
let mut s = Session::new();
let p1 = s.page().unwrap();
let p2 = id(s.execute("page.add", &json!({})).unwrap());
let mut u = Ui::new(s);
let r2 = page_row(&mut u, p2, p1);
u.frame(vec![
Event::PointerMoved(r2),
Event::PointerButton { pos: r2, button: egui::PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
]);
u.frame(vec![Event::PointerButton { pos: r2, button: egui::PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }]);
let shapes: Vec<egui::Shape> = u.frame_output(vec![]).shapes.into_iter().map(|c| c.shape).collect();
let item = crate::live_tests::text_center(&shapes, "Rename page");
u.frame(vec![Event::PointerMoved(item)]);
u.button(item, true);
u.button(item, false);
for _ in 0..3 {
u.frame(vec![]);
}
assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p2), "the menu renames the page");
assert!(u.ctx.memory(|m| m.has_focus(Id::unique(("pagename", p2)).with("te"))), "with the keyboard in its name");
u.frame(vec![Event::Text("Renamed".into())]);
press_page_field_key(&mut u, egui::Key::Enter);
assert_eq!(u.app.session.doc().unwrap().node(p2).map(|n| n.name.clone()).as_deref(), Some("Renamed"));
assert_clicking_page_opens_it(&mut u, p2, p1, r2);
}
/// The y of the line between the pages and the layers, above the layers' row at `layer` (found by
/// the resize cursor it shows when hovered).
fn pages_edge_at(u: &mut Ui, layer: f32) -> f32 {
+3
View File
@@ -754,6 +754,9 @@ pub struct FigApp {
panels_unsaved: bool,
/// Control-channel requests waiting for the next frame.
pub control_queue: Vec<ControlRequest>,
/// Windows a control request waits for: the window's pass count when the wait began and the
/// app-clock time it began, to tell a window that stopped drawing ([`control::background`]).
control_waits: std::collections::BTreeMap<u64, (u64, f64)>,
/// Input events from the control channel (`ui.input`) for the next frame's raw input.
pub control_input: Vec<egui::Event>,
/// Frames drawn and what asked for them (`ui.inspect`).
+7 -1
View File
@@ -293,8 +293,13 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0));
let start = ui.data_mut(|d| d.remove_temp::<bool>(page_rename_start_id())).unwrap_or(false);
let font = if id == cur { semibold(11.0) } else { regular(11.0) };
if let (Some(new), _) = crate::widgets::rename_field(ui, Id::unique(("pagename", id)), &name, tr, font, start) {
let (out, open) = crate::widgets::rename_field(ui, Id::unique(("pagename", id)), &name, tr, font, start);
if let Some(new) = out {
rename_done = Some((id, new));
} else if !open {
// Cancelled, or committed unchanged: the row goes back to its name, or the idle
// field would catch the next click and a click wouldn't open this page.
app.ui.rename_page = None;
}
} else if !divider {
let font = if id == cur { semibold(11.0) } else { regular(11.0) };
@@ -315,6 +320,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
if ui.button(tr("Rename page")).clicked() {
if !crate::menus::dev_mode_refuses(app, "page.rename") {
app.ui.rename_page = Some((id, name.clone()));
ui.data_mut(|d| d.insert_temp(page_rename_start_id(), true));
}
ui.close();
}
+1 -1
View File
@@ -53,7 +53,7 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N
let radius_ok = crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_radius());
// Only rectangles and frames round each corner on its own.
let indep_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_independent_corners());
// For polygons and stars the same panel holds only the Corner smoothing row.
// For polygons, stars and closed vectors the same panel holds only the Corner smoothing row.
let smooth_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_smoothing());
let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0));
widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius")));
+22
View File
@@ -54,6 +54,28 @@ fn arrows_pan_the_view_when_nothing_is_selected() {
assert_eq!(u.app.session.doc().unwrap().world_bounds(r).unwrap().x0, 1.0);
}
/// With a shape or frame tool chosen and nothing selected, the arrows show the placement crosshair
/// in the middle of the view and move it (Shift: the large nudge) instead of panning, and Enter
/// places the object there (Accessibility at Figma, #1839).
#[test]
fn arrows_show_and_move_the_placement_crosshair_with_a_shape_tool() {
let mut u = Ui::new(Session::new());
u.app.run("select.set", json!({"ids": []}));
u.app.run("view.set", json!({"zoom": 2.0, "cx": 100, "cy": 50}));
u.app.run("tool.rectangle", json!({}));
press(&mut u, Key::ArrowRight, Modifiers::NONE);
assert_eq!(view(&u), (100.0, 50.0, 2.0), "no pan: the crosshair shows instead");
assert_eq!(u.app.session.tool.place_at.map(|p| (p.x, p.y)), Some((100.0, 50.0)), "in the middle of the view");
press(&mut u, Key::ArrowDown, Modifiers::SHIFT);
assert_eq!(u.app.session.tool.place_at.map(|p| (p.x, p.y)), Some((100.0, 60.0)), "moved by the large nudge");
press(&mut u, Key::Enter, Modifiers::NONE);
let sel = u.app.session.selection();
let b = u.app.session.doc().unwrap().world_bounds(sel[0]).unwrap();
assert_eq!((b.center().x, b.center().y), (100.0, 60.0), "placed at the crosshair");
assert!(u.app.session.tool.place_at.is_none(), "which goes with the placement");
assert_eq!(view(&u), (100.0, 50.0, 2.0));
}
#[test]
fn keyboard_box_selection_key_and_cmd_arrows() {
let mut s = Session::new();
+5 -1
View File
@@ -480,7 +480,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
_ => (0.0, 0.0),
};
if (dx, dy) != (0.0, 0.0) {
if app.session.selection().is_empty() {
if app.session.selection().is_empty() && app.session.tool.tool.draws_shape() && !app.session.tool.dev_mode && !m.command && !m.alt {
// A shape or frame tool chosen: the arrows show the placement crosshair in the middle
// of the view and move it; Enter places there (Accessibility at Figma).
app.run("tool.crosshair", json!({"dx": dx, "dy": dy, "big": m.shift}));
} else if app.session.selection().is_empty() {
app.run("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift}));
} else if crate::menus::dev_mode_refuses(app, "transform.nudge") {
// Dev Mode: said so, nothing moves.
+2 -1
View File
@@ -271,7 +271,8 @@ pub fn on_dark<R>(ui: &mut egui::Ui, f: impl FnOnce(&mut egui::Ui) -> R) -> R {
/// UI fonts: Inter and the Archivo wordmark (from the craft-fonts build input) when present;
/// otherwise egui's defaults. With
/// `system`, also system faces for scripts those lack (Hebrew, joined Arabic) so names in them show,
/// `system`, also system faces for scripts those lack (Hebrew, joined Arabic, Ethiopic, the Indic
/// scripts and the others [`crate::bidi::script_fallbacks`] names) so names in them show,
/// and faces for the CJK scripts in `cjk` ([`crate::cjk`]).
///
/// The craft-fonts set embeds Noto Sans Arabic and Noto Sans Hebrew; with `system` off those
+41 -3
View File
@@ -1,5 +1,6 @@
//! A canvas pan (middle button or Space + primary) ends when its button is released, even when the
//! release happens over a panel: returning to the canvas with no button held must not move the view.
//! A canvas pan (middle button, a side button, or Space + primary) ends when its button is
//! released, even when the release happens over a panel: returning to the canvas with no button held
//! must not move the view.
use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, vec2};
use figcraft_engine::Session;
@@ -45,6 +46,12 @@ fn space(pressed: bool) -> Event {
}
fn check(space_pan: bool, outside: bool) {
check_button(if space_pan { PointerButton::Primary } else { PointerButton::Middle }, space_pan, outside);
}
/// A pan with button `b` (with Space held when `space_pan`) moves the view, ends when `b` is
/// released (`outside` the canvas or over it), and a new one moves it again.
fn check_button(b: PointerButton, space_pan: bool, outside: bool) {
let mut u = Frames::new();
let rect = u.app.canvas.rect;
let start = Pos2::new(rect.min.x + 200.0, rect.center().y);
@@ -55,7 +62,6 @@ fn check(space_pan: bool, outside: bool) {
if space_pan {
u.frame(vec![space(true)]);
}
let b = if space_pan { PointerButton::Primary } else { PointerButton::Middle };
let before = u.cx();
u.frame(vec![button(start, b, true)]);
u.frame(vec![Event::PointerMoved(dragged)]);
@@ -93,3 +99,35 @@ fn space_pan_released_over_panel_does_not_move_view_on_return() {
fn middle_pan_released_inside_canvas_keeps_view() {
check(false, false);
}
/// #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, and the pan
/// ends on release, over the canvas or off it.
#[test]
fn side_buttons_pan_like_the_middle_button() {
for b in [PointerButton::Extra1, PointerButton::Extra2] {
check_button(b, false, false);
check_button(b, false, true);
}
}
/// A primary drag under way when a pan starts (a side or the middle button pressed mid-drag) ends
/// when the primary button is let go during the pan: the engine gets the release, so its gesture
/// isn't left open once the pan is over.
#[test]
fn a_primary_drag_released_during_a_pan_ends() {
for pan in [PointerButton::Middle, PointerButton::Extra1] {
let mut u = Frames::new();
let rect = u.app.canvas.rect;
let start = Pos2::new(rect.min.x + 200.0, rect.center().y);
let moved = Pos2::new(rect.min.x + 400.0, rect.center().y);
u.frame(vec![Event::PointerMoved(start)]);
u.frame(vec![button(start, PointerButton::Primary, true)]);
u.frame(vec![Event::PointerMoved(moved)]);
assert!(u.app.session.tool.busy(), "{pan:?}: a primary drag is under way");
u.frame(vec![button(moved, pan, true)]);
u.frame(vec![button(moved, PointerButton::Primary, false)]);
u.frame(vec![button(moved, pan, false)]);
u.frame(vec![]);
assert!(!u.app.session.tool.busy(), "{pan:?}: the primary release reached the engine");
}
}
+8 -5
View File
@@ -1,6 +1,6 @@
# FigCraft architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (the `vectordrawable` crate, L4: Android Vector Drawable export; earlier: the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (corrected the file I/O overview to include the existing best-effort `.fig` import and experimental export; earlier: the `vectordrawable` crate, L4: Android Vector Drawable export; the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
How FigCraft is built today. Engine first, a thin swappable UI, everything a command; a Figma-shaped
data model (a scene graph of frames with auto layout, components and variables). ~108,800 lines of
@@ -23,7 +23,7 @@ replaced.
| L2 | `figcraft-plugin` | ~1.0k | Plugin manifest and the WebAssembly sandbox (wasmi, metered, memory cap, no file system, network or clock) |
| L2 | `figcraft-pathops` | ~1.1k | Booleans (linesweeper), outline stroke with alignment, arrowheads |
| L3 | `figcraft-render` | ~5.4k | Scene → pixels on tiny-skia (CPU): isolated layers for opacity/blend/effects, clips, masks, shadows, blurs (uniform, progressive, background), noise, texture, glass, text; banded in parallel; retained rendering of only what changed; levels of detail when zoomed out |
| L3 | `figcraft-format` | ~5.2k | Native `.figcraft` (zip of `document.json` + images; streamed JSON), local library files, versions, REST API file JSON import (`rest/`) |
| L3 | `figcraft-format` | ~5.2k | Native `.figcraft` (zip of `document.json` + images; streamed JSON), local library files, versions, REST API file JSON import (`rest/`), best-effort `.fig` import and experimental export (`fig` module) |
| L4 | `figcraft-svg` | ~3.7k | SVG export and import (usvg) |
| L4 | `figcraft-pdf` | ~2.3k | PDF export (krilla) with print options |
| L4 | `figcraft-vectordrawable` | ~1.2k | Android Vector Drawable (XML) export: baked path data, gradients through `aapt:attr`, clip groups, compatibility modes and warnings |
@@ -67,9 +67,11 @@ express an effect.
`.figcraft` is the native format (atomic saves, autosave checkpoints; the desktop app keeps
recovery copies of unsaved documents that reopen after a crash or a forced quit). Imports: REST API
file JSON with variables and images, SVG, raster images, clipboard
content. Exports: PNG, JPG, WebP, SVG, PDF, code. There is no `.fig` reader yet
([`file-format-parity.md`](file-format-parity.md)).
file JSON with variables and images, SVG, raster images, clipboard content and `.fig` files (best
effort). Exports: PNG, JPG, WebP, SVG, PDF, code and `.fig` (experimental). The `.fig`
[reader](../crates/format/src/fig.rs) and [writer](../crates/format/src/fig/export.rs) belong to
`figcraft-format`; fidelity limits and validation are documented in
[`file-format-parity.md`](file-format-parity.md).
## Robustness
@@ -83,6 +85,7 @@ input.
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | minor | Corrected the file I/O overview and format-crate row: best-effort `.fig` import and experimental export already exist in `figcraft-format`. No capability or readiness change. |
| 2026-10-18 | minor | Added `vectordrawable` (L4), the Android Vector Drawable (XML) writer |
| 2026-10-11 | minor | `collab-net` hosts through one hub for the app's sessions and the standalone server; added the `figcraft-server` app |
| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) |
+4 -2
View File
@@ -59,8 +59,10 @@ queue. A screenshot settles on three completed, presented UI frames (a discarded
not count), then is issued, and expires once at its 60 s deadline if no paint arrives. `window.new`
remains an error at once (`window 0 is minimised or hidden: …`) instead of waiting for the window
to show; `ui.render` draws the document without it. Under Wayland the app can't tell that its
window is minimised: where the compositor then stops drawing it, a request waits for the window
and gets the 60 s timeout error. The browser's shared eframe runner does not implement
window is minimised, and the compositor stops asking it to draw: a window that draws no frame for
0.2 s while a request waits for it is treated as hidden, so the requests that act on the app's
state are answered without a frame (about half a second each), while `ui.screenshot` and
`ui.input` keep waiting for one. The browser's shared eframe runner does not implement
`RequestPaintWhileHidden` yet, so hidden-tab screenshots and injected input are unqualified there.
Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are relative to the parent
+2 -2
View File
@@ -384,8 +384,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
- **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together,
sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG,
PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a
selected open path. Left: corner smoothing on vectors, corner radius and smoothing on boolean
groups that contain rectangles, per-point caps and joins on dashed, variable-width and brush
selected open path. Left: corner radius and smoothing on boolean groups that contain
rectangles, per-point caps and joins on dashed, variable-width and brush
strokes.
- **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a
subtracted hole reaches what's behind; with the group or an operand selected its operands are
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** patch (under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@@ -41,7 +41,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|---|---|---|---|
| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); the Quit menu and ⌘Q ask about unsaved changes like the main window's close button (#857), and like it close the main window only: with several windows open, each quit closes one; a quit that never asked — killed or crashed — still leaves recovery copies for the next start |
| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. The portable zip keeps its settings in `config\` beside `figcraft.exe` (a `portable` file there), taking an installed copy's preferences on its first start (#1320). Draws with Direct3D 12 (FXC shaders) or OpenGL; when the graphics fail before the first frame it starts again on Vulkan, then OpenGL, then the power-saving GPU with lower limits (#965) |
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised: a window that draws nothing for 0.2 s while a control request waits for it answers the request as a hidden window does (about half a second each, #1906), and only requests that need a drawn frame (screenshots, input) wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
| FreeBSD | none | tarball | |
| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event, its text or an image copied in another app); an internal error stops the page (a panic can't be caught there), but its unsaved files are kept in the page's local storage (about 5 MB) and open again on reload (#1123) |
| iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer |
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | patch | M8, #1906: under Wayland (seen on GNOME and KDE Plasma) a minimised FigCraft window still reports itself visible and the compositor stops asking it to draw, so every control or attached-MCP request waited for a frame and timed out after 60 s ("it was not applied"), though eframe kept running the app's logic. `control::background` now treats a window that drew no frame for `NOT_DRAWING_AFTER` (0.2 s) while a request waited for it as hidden: state-only requests are answered from the logic passes (0.54 s each, measured on GNOME Wayland; they took 13.7 s or timed out before), the wait starts again when the window draws, and screenshots and `ui.input` still wait for a frame. Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes` |
| 2026-10-31 | minor | M1, #492: Web row: the browser pastes images copied in other apps, read from the paste event under the same limits as the desktop. Numbers unchanged. |
| 2026-10-30 | minor | M8: text reads only the fallback fonts it draws with. Each fallback family reads the face nearest a run's style, not all of them (Noto Sans CJK is seven files of 16–20 MB); families naming the faces of one file share its bytes; and the faces are worked out once per style until fonts change, not for every text laid out. First text laid out on a Linux machine with Noto Sans CJK, DejaVu Sans and Inter: 41 files (265 MiB, kept in memory) and 41 faces parsed → 3 files (20 MiB) and 4 faces; fallback lookups per text laid out ~45 µs → 0.2 µs. Tests: `fontdb_fallback_tests`. Numbers unchanged |
| 2026-10-30 | minor | M8, #1117: AI agents and plugins no longer learn where the open files are on disk from `doc.list`. Numbers unchanged. |
+9 -4
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (documents: a paragraph's base direction can be forced with Text ▸ Text direction, as Add right-to-left text says (#1830); Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (; documents: a paragraph's base direction can be forced with Text ▸ Text direction, as Add right-to-left text says (#1830); Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
@@ -60,8 +60,12 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
layers; a paragraph's base direction forced with Text ▸ Text direction (#1830); right-to-left
list markers and indents; right-to-left names in the layers panel, tabs
and canvas labels (visual order, joined Arabic); inline IME composition with the candidate window
at the caret; system-font fallback for scripts the UI fonts lack. Devanagari shapes through
harfrust. No vertical text (Figma has none either).
at the caret; system-font fallback for scripts the UI fonts lack: Hebrew and Arabic, Bengali
and Thai (with the CJK faces), and since #2014 Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and
Georgian, so a layer name or a String variable's value in them
draws in the panels when the system has a face (`crates/ui-egui/src/bidi.rs`, `SCRIPT_SAMPLES`
and `SCRIPT_FAMILIES`). Devanagari shapes through harfrust. No vertical text (Figma has none
either).
- **Script support in the UI:** menus widen to their longest label; dropdowns, the two-column
labels of the right panel and the home screen's cards keep a long translation on one line with an
ellipsis and show it in full on hover (checked in German, the longest); no mirrored
@@ -91,7 +95,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
| English | en | 2,352 / 2,352 (100%, source) | yes | Latin | yes (source) | full | 0 |
| Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) |
| Spanish | es, es-419 | 2,352 / 2,352 (100%, draft); es-419 overlays 104 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) |
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 |
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; the desktop UI takes an installed Devanagari face (Nirmala UI, Kohinoor Devanagari, Noto Sans Devanagari, Lohit) for names and values (#2014); no UI catalog | no | none | 4–7 |
| Arabic | ar | 1,857 / 1,857 UI strings, 620 command labels, 31 menu wordings (draft, counted 2026-10-28) | catalog complete; native review pending | Existing bidi/shaping and Arabic system fallback; web not offered (font budget excludes Arabic); panels do not mirror | no | draft | review + panel mirroring (not re-estimated) |
| French | fr | 2,328 / 2,352 (99.0%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Portuguese | pt-BR | 2,331 / 2,352 (99.1%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
@@ -126,6 +130,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | patch | #2014: the panels drew a String variable's Amharic value as boxes: the UI took a system face for Hebrew and Arabic only. `bidi::script_fallbacks` now samples one letter of each of 16 scripts (Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as well as Hebrew and Arabic; Bengali and Thai keep `crate::cjk`'s faces) against the system's families for each (Windows, macOS, the common Linux fonts), the first installed one that draws the letter taken once. Desktop only: the web build has no system fonts. Test: `bidi::tests::other_scripts_take_an_installed_system_face` (skips scripts the machine has no face for). |
| 2026-11-05 | minor | Documents: a paragraph's base direction can be forced left to right or right to left (Text ▸ Text direction, `text.direction`, `node.set textDirection`, the Type settings control, the `.fig` lines' `sourceDirectionality`), as Add right-to-left text says (#1830); detection stays the default. Four new UI strings (English only; other catalogs fall back). |
| 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) |
| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged |
+2 -1
View File
@@ -42,7 +42,8 @@ replaced then, so a pause between calls is not an error.
The app keeps answering while its window is minimised or covered by other windows (see
`docs/control-protocol.md`): edits, inspection (`inspect_ui` too), pointer, key and text input and
renders work as usual (minimised, each request to the app takes up to about 0.1 s). Only a capture
renders work as usual (minimised, each request to the app takes up to about 0.1 s; under Wayland,
which can't report a minimised window, about half a second). Only a capture
of the window itself (`screenshot` with `window`) needs it on screen: it is then an error at once;
`screenshot` without `window` renders the document instead.
+7 -5
View File
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping Line truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effectLine truncated
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@@ -99,7 +99,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 1 | Canvas & navigation | 6 | 98% | 74% | Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks; outline mode's Include hidden layers and Include object bounds landed) | 1–4 |
| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 |
| 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 |
| 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on vectors, corner radius and smoothing on boolean groups that contain rectangles; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons and stars, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 |
| 4 | Shapes & vector | 8 | 99% | 73% | Corner radius and smoothing on boolean groups that contain rectangles; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons, stars and closed vectors, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 |
| 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 |
| 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 |
| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 |
@@ -298,7 +298,7 @@ Hours in this narrative are the builders' estimates; the tables above supersede
break inside Latin words, kinsoku to JIS X 4051 for closing and opening punctuation, small kana
(halfwidth too), the prolonged sound mark, the iteration marks, the (semi-)voiced sound marks
and the halfwidth parentheses); Thai, Lao, Khmer and Burmese lines break between words (ICU4X's word
dictionaries, its LSTM models in the browser), never after a leading vowel or before a mark; a style's metrics from its own font, not a fallback; a letter and
dictionaries, its LSTM models in the browser), never after a leading vowel or before a mark; justified text widens each line's spaces but the last line's, and in Chinese, Japanese and Korean text, which has none, the gaps between its characters wherever a line could break, a gap beside a right-to-left word opening beside the word (#1970); a style's metrics from its own font, not a fallback; a letter and
its combining marks (Vietnamese typed decomposed, say) from one font that has them all; carets, Delete and
Backspace by grapheme cluster; double-click and word moves by script run; IME composition with
CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`,
@@ -643,14 +643,16 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
`figcraft-cli mcp` and the desktop app, #715). A minimised or covered window answers every
request that acts on the app's state without drawing (about 100 ms each on Windows), and the
ones that need a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) fail at once instead of
waiting 60 s (#720; not tested on macOS, and under Wayland a minimised window can't be told
apart).
waiting 60 s (#720; not tested on macOS). Under Wayland a minimised window can't be told apart,
so a window that draws nothing for 0.2 s while a request waits is answered as a hidden one
(about half a second a request, #1906).
- **Tests:** 963 `#[test]` functions (2026-10-10) including junk-parameter sweeps over every command (and every vector-edit command while editing), importer fuzzing and render regressions.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. A gap is reordered with the line's runs as a space of no width would be, so one beside a Hebrew word in Chinese text, or beside a Chinese word within that Hebrew, opens beside the word, not inside it. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). |
| 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first; a loop listed out of walking order is left as it was. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). |
| 2026-11-06 | patch | M3: glass's docs say which centre `splay` bends away from: the centre of the layer's bounds, as the reference tool's, checked side by side on a ring and an L at Splay 0% and 100% (an inner edge bends towards the hole in both). And where a glass shape gets no core: past 4M px on the desktop and 1M on the web (`MAX_SHAPE_PX`) it bends along its edge normals, so a large glass text or vector can bend differently once zoomed into, in a 2× export, or on the web. Docs only. Grade unchanged. |
| 2026-11-02 | minor | M3, #1885: glass bends towards its core, the shape shrunk by `depth` and the bevel, as the reference tool's: round a sharp corner the bend turns smoothly, and a thin part (a capsule, a letter's stroke) bends across itself, checked side by side on a capsule 44 units tall. Rounded rectangles work it out in closed form. A shape too big to keep whole at the zoom bends along its edge normals instead, so no render works out a core over more pixels than a kept shape holds. Tests: `glass::tests::a_sharp_corner_bends_round_towards_the_core`, `glass::tests::a_thin_part_bends_across_itself`, `glass::tests::a_shape_too_big_to_keep_bends_along_its_normals`, `glass_cut_tests::a_cut_through_the_pane_matches_the_whole_render`. Grade unchanged. |
+5 -3
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (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); 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 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 thresholdLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (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); 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 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 Line truncated
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -61,7 +61,7 @@ interaction feel not yet compared.
| Rotate just outside a corner; Shift in 15° steps | done | rotated handle box when layers share a rotation |
| Flip, rotate 90° | done | |
| Rotation origin (Alt+R) | done | a handle at the selection's centre to drag (snaps to the box's corners, edge middles and centre), double-click puts it back, Escape or Alt+R hides it; the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about it; it follows the layers and lasts as long as the selection, not saved (the documentation doesn't say; pinned) |
| Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. No crosshair to move with the arrows first |
| Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. With nothing selected, the arrows first show a crosshair in the middle of the view and then move it by the nudge amounts (`tool.crosshair`; Shift the large one), and Enter places there; Escape or a tool change takes it away ("Use the arrow keys to activate the crosshair cursor. It will appear in the middle of the viewport indicating where the new object will be placed", Accessibility at Figma; #1839) |
| Alt-drag duplicate | done | the copy snaps to the layer it came from too (edges, centres, equal spacing); Duplicate (Cmd+D) then repeats the copy's offset, also after a Duplicate whose copy was moved, as long as the copies stay selected (the documentation doesn't say; pinned from community answers) |
| Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) |
| Scale tool (K): handles about an anchor, live factor, panel | done | |
@@ -81,7 +81,7 @@ interaction feel not yet compared.
| Tool | Status | Notes |
|---|---|---|
| Move, hand (Space), zoom (hold Z, Alt out, drag area) | done | |
| Move, hand (Space), zoom (hold Z, Alt out, drag area) | done | the middle mouse button and a mouse's side buttons (4 and 5) pan as Space does (#1758) |
| Frame (presets panel, swap orientation), section, slice | done | |
| Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) |
| Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only |
@@ -180,8 +180,10 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | patch | M1, #1837: scrubbing speed by the pointer's height (`widgets::scrub_speed`: 2x more than 100 points above where the scrub began, 1/2 that far below, 1/4 twice as far), a toast naming the speed when it changes, Shift and Alt still multiplying; the Keyboard shortcuts panel lists the gesture ("There are four scrubbing speeds: 2x, 1x, 1/2, and 1/4", Scrub fields). Test: `field_tests::moving_up_or_down_while_scrubbing_changes_the_speed_and_says_so`. |
| 2026-11-02 | patch | M6, #1839: 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 by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |
| 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. |
| 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. |
| 2026-11-05 | patch | M1, #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, the pan ending on release over the canvas or off it; the grab cursor shows while they're held. |
| 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`. |