From 3e02bffbbc72d3baece8ea4096ef7a9cfaf5db1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E6=A9=A6?= <108113065+yelintong@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:51:57 +0800 Subject: [PATCH 01/16] Layout grids: Auto line width for columns and rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Columns and rows can now set their line width to Auto: a width of 0 (or typing the word "Auto" / "自动" in the field) draws a one-pixel hairline down the band's middle, like the square grid, instead of a filled band. - grid_lines: a non-stretched grid with size <= 0 emits a hairline at each band's middle rather than a zero/negative-width rect. - canvas: a degenerate (zero-width or zero-height) grid rect is painted as a 1px line; a filled band is still painted as a rectangle. - number_field: a field whose placeholder is "Auto" also accepts the word "Auto"/"自动", mapping it to 0. - right: the grid size field shows "Auto" and the align menu no longer forces a 60px band on a zero width. - props: document the Auto semantics of LayoutGrid::size. - ROADMAP.md / docs/ui-parity.md: note the change. - layout tests: regression test for the Auto hairline. --- ROADMAP.md | 3 ++- crates/doc/src/props.rs | 4 +++- crates/layout/src/lib.rs | 11 ++++++++++- crates/layout/src/tests.rs | 24 ++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 7 ++++++- crates/ui-egui/src/right.rs | 11 +++++++---- crates/ui-egui/src/widgets.rs | 19 +++++++++++++++---- docs/ui-parity.md | 5 +++-- 8 files changed, 70 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..acd5bfbb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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 behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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 behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) 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 @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Layout grids: columns and rows can set their line width to Auto (a width of 0, or typing the word “Auto” / “自动” in the field), drawing a one-pixel hairline down each band's middle like the square grid instead of a filled band; the width field shows “Auto” as its placeholder and clears to Auto. The align menu no longer forces a 60 px band on a zero width (it keeps Auto); only stale sub-pixel widths are lifted. Regression test in the layout suite. Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 4f6491c1..38824149 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1189,7 +1189,9 @@ pub enum GridAlign { #[serde(default)] pub struct LayoutGrid { pub kind: GridKind, - /// Grid: cell size. Fixed (not stretched) columns/rows: their width/height. + /// Grid: cell size. Fixed (not stretched) columns/rows: their width/height. 0 (or Auto) draws + /// a one-pixel hairline like the square grid; a positive value draws a band that many + /// document units wide. pub size: f64, /// Columns/rows: how many (0: Auto, as many fixed-size ones as fit; a stretched grid has 1). pub count: u32, diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 02e7bdaf..3adeaf33 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1047,6 +1047,9 @@ pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec { let total = if cols { w } else { h }; let gutter = if g.gutter.is_finite() { g.gutter.max(0.0) } else { 0.0 }; let offset = if g.offset.is_finite() { g.offset.max(0.0) } else { 0.0 }; + // Auto width (size 0, a non-stretched grid) draws a one-pixel hairline down the + // band's middle, like the square grid; otherwise a filled band of `size`. + let auto = g.size <= 0.0 && g.align != GridAlign::Stretch; let count = match g.count { // Auto: as many fixed-size columns (rows) as fit in the room the type leaves them. 0 if g.align != GridAlign::Stretch => { @@ -1074,7 +1077,13 @@ pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec { }; for i in 0..count { let a = start + i as f64 * (size + gutter); - out.push(if cols { Rect::new(a, 0.0, a + size, h) } else { Rect::new(0.0, a, w, a + size) }); + if auto { + // A one-pixel hairline down the band's middle (the square grid's lines). + let c = a + size / 2.0; + out.push(if cols { Rect::new(c, 0.0, c, h) } else { Rect::new(0.0, c, w, c) }); + } else { + out.push(if cols { Rect::new(a, 0.0, a + size, h) } else { Rect::new(0.0, a, w, a + size) }); + } } } } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 0af9861b..81e0500c 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -447,3 +447,27 @@ fn grid_hostile_values() { assert!(x.is_finite() && y.is_finite() && w.is_finite() && h.is_finite()); } } + +#[test] +fn grid_lines_auto_width_columns_rows_hairline() { + // Columns with Auto width (size 0, non-stretched) draw one-pixel hairlines at each band's + // middle, like the square grid; a set width draws filled bands. + let g = LayoutGrid { kind: GridKind::Columns, align: GridAlign::Min, count: 3, size: 0.0, gutter: 10.0, offset: 0.0, ..LayoutGrid::default() }; + let lines = grid_lines(&g, 300.0, 50.0); + assert_eq!(lines.len(), 3, "three hairlines"); + for r in &lines { + assert!((r.x0 - r.x1).abs() < 1e-9 && (r.y0 - r.y1).abs() > 1.0, "a vertical hairline: {r:?}"); + } + // A positive width draws filled bands again. + let g = LayoutGrid { kind: GridKind::Columns, align: GridAlign::Min, count: 3, size: 8.0, gutter: 10.0, offset: 0.0, ..LayoutGrid::default() }; + let lines = grid_lines(&g, 300.0, 50.0); + assert!(lines.iter().all(|r| (r.x1 - r.x0).abs() > 1e-9), "a set width draws bands"); + + // Rows Auto width: horizontal hairlines. + let g = LayoutGrid { kind: GridKind::Rows, align: GridAlign::Min, count: 2, size: 0.0, gutter: 10.0, offset: 0.0, ..LayoutGrid::default() }; + let lines = grid_lines(&g, 50.0, 200.0); + assert_eq!(lines.len(), 2, "two hairlines"); + for r in &lines { + assert!((r.y0 - r.y1).abs() < 1e-9 && (r.x0 - r.x1).abs() > 1.0, "a horizontal hairline: {r:?}"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9e0c1d79..d8ae41ec 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -923,7 +923,12 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ let a = w * Point::new(r.x0, r.y0); let b = w * Point::new(r.x1, r.y1); let (sa, sb) = (app.to_screen(a.x, a.y), app.to_screen(b.x, b.y)); - if g.kind == figcraft_doc::GridKind::Grid { + // The square grid and an Auto-width line draw one-pixel hairlines; a filled + // band (a column/row with a set width) is painted as a rectangle. + if g.kind == figcraft_doc::GridKind::Grid + || (r.x0 - r.x1).abs() < 1e-9 + || (r.y0 - r.y1).abs() < 1e-9 + { painter.line_segment([sa, sb], stroke(col, 1.0)); } else { painter.rect_filled(Rect::from_two_pos(sa, sb), CornerRadius::ZERO, col); diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 53eaf953..f433a48a 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -2069,12 +2069,13 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr menu_popup(&resp).show(|ui| { for (k, (name, a)) in names.iter().zip(aligns).enumerate() { if menu_check(ui, k == cur, name, "") { - // Fixed-size columns need a size of their own. + // Keep Auto (size 0); only lift stale sub-pixel sizes (0 < size < 1) to a + // sensible band, so a width typed as 0 stays Auto. app.run( "node.set", set(&|x| { x.align = a; - if a != GridAlign::Stretch && !(x.size.is_finite() && x.size > 0.0) { + if a != GridAlign::Stretch && x.size > 0.0 && x.size < 1.0 { x.size = 60.0; } if a == GridAlign::Stretch { @@ -2099,14 +2100,16 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr GridAlign::Center => { widgets::sublabels(ui, span, Some(tr("Gutter"))); row(ui, 32.0, |ui| { - field(app, ui, "size", g.size, &size); + let e = crate::variable_ui::number_field_or(app, ui, ids, &format!("layoutGrids/{i}/size"), Id::new(("grid_field", "size", i)), Some(g.size), col, tr("Auto"), Lead::None, ""); + app.field_run(e, "node.set", |v| set(&|x| x.size = v.max(0.0))); field(app, ui, "gutter", g.gutter, &gutter); }); } GridAlign::Min | GridAlign::Max => { widgets::sublabels(ui, span, Some(tr("Offset"))); row(ui, 32.0, |ui| { - field(app, ui, "size", g.size, &size); + let e = crate::variable_ui::number_field_or(app, ui, ids, &format!("layoutGrids/{i}/size"), Id::new(("grid_field", "size", i)), Some(g.size), col, tr("Auto"), Lead::None, ""); + app.field_run(e, "node.set", |v| set(&|x| x.size = v.max(0.0))); field(app, ui, "offset", g.offset, &offset); }); widgets::sublabels(ui, tr("Gutter"), None); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 940d7a79..294bd36c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -259,10 +259,21 @@ fn number_field_impl( }; // A relative edit of no single value goes to each layer's own. let set = |v: f64| Edit::All(FieldEdit::Set(v)); - let typed = |text: &str| match (parse(text, suffix == "%")?, value) { - (Typed::Value(v), _) => Some(set(v)), - (Typed::Rel(r), Some(cur)) => r.apply(cur).map(set), - (Typed::Rel(r), None) => Some(Edit::Each(r)), + let typed = |text: &str| { + // A field whose placeholder is "Auto" (grid width/count, auto-gap, …) also takes the + // word "Auto" (or the Chinese "自动") as input, setting it to 0 — its automatic value. + let auto_token: &str = &*tr("Auto"); + if empty == auto_token { + let t = text.trim(); + if t.eq_ignore_ascii_case("auto") || t == "自动" { + return Some(set(0.0)); + } + } + match (parse(text, suffix == "%")?, value) { + (Typed::Value(v), _) => Some(set(v)), + (Typed::Rel(r), Some(cur)) => r.apply(cur).map(set), + (Typed::Rel(r), None) => Some(Edit::Each(r)), + } }; // With the keyboard on the field (Tab) and nothing typed yet, the arrows step it as it is. if editing.is_none() diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 766284f1..3ea38a0a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -105,7 +105,7 @@ interaction feel not yet compared. | Frames and sections drawn around layers take those fully inside; sections never drop into frames | done | not in auto layout frames | | Resizing a section leaves its contents in place | done | | | Masks: what a mask hides doesn't take clicks; a mask group's box is the mask's | done | | -| Layout grids (Ctrl+G) | done | columns, rows and grid; stretch, left, centre, right; Auto count; several per frame; styles; not shown or snapped on a rotated frame; a nested frame's grids snap but only top-level frames draw them | +| Layout grids (Ctrl+G) | done | columns, rows and grid; Auto line width (0 draws a one-pixel hairline); stretch, left, centre, right; Auto count; several per frame; styles; not shown or snapped on a rotated frame; a nested frame's grids snap but only top-level frames draw them | | Gamut warnings for Display P3 files | done | colour picker note; View ▸ Soft-proof sRGB stripes what sRGB can't show (the canvas itself is sRGB) | | Gradient and padding/gap handles on the canvas | done | | | Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Layout grids: columns and rows take an Auto line width (0 or “Auto”), drawing a one-pixel hairline down each band's middle; the width field shows “Auto” and typing it clears the width. The align menu keeps a zero width as Auto instead of forcing 60 px. Regression test added. | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | From 8450d18c8e1d390441573bb25acc312fb5d4373a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E6=A9=A6?= <108113065+yelintong@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:13:37 +0800 Subject: [PATCH 02/16] Layout grids: Auto width takes the word only where 0 is Auto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for #881 (FredApps, CodeRabbit). - number_field: the word for Auto (the placeholder's own text in the UI's language, or "auto") is taken only by a field that asks for it (`Empty::Auto`), not by every field whose placeholder reads "Auto". An auto layout's gap ("Auto" is Space between) and a prototype overlay's offset ("Auto" is no offset) keep their own meaning of 0; the fixed "自动" match is gone. - layout: Auto width has no hidden 60 px band — the bands step by the gutter alone, so the pitch is what the panel shows. A zero-width band snaps to one line, not two identical edges. - right: the width field shows "Auto" (not 0) when there is no width, and a stretched grid shows it dim and not editable (its bands divide the frame), with the margin and gutter still editable. - tests: the field suite checks the word on a grid's width (0) and on an auto layout's gap (nothing); the layout suite pins the gutter pitch, the Auto count and a stretched grid's bands. - docs: ROADMAP and ui-parity state the pitch and what changes for saved grids. --- ROADMAP.md | 2 +- crates/doc/src/props.rs | 5 +- crates/layout/src/lib.rs | 26 ++++++---- crates/layout/src/tests.rs | 16 ++++++- crates/ui-egui/src/field_tests.rs | 76 +++++++++++++++++++++++++++++ crates/ui-egui/src/proto_panel.rs | 15 +++++- crates/ui-egui/src/right.rs | 46 ++++++++++++++---- crates/ui-egui/src/variable_ui.rs | 24 ++++++++-- crates/ui-egui/src/widgets.rs | 79 ++++++++++++++++++++++++++----- docs/ui-parity.md | 4 +- 10 files changed, 250 insertions(+), 43 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index acd5bfbb..b9851508 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,7 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-19 | minor | Layout grids: columns and rows can set their line width to Auto (a width of 0, or typing the word “Auto” / “自动” in the field), drawing a one-pixel hairline down each band's middle like the square grid instead of a filled band; the width field shows “Auto” as its placeholder and clears to Auto. The align menu no longer forces a 60 px band on a zero width (it keeps Auto); only stale sub-pixel widths are lifted. Regression test in the layout suite. Numbers unchanged. | +| 2026-10-19 | minor | Layout grids: columns and rows can set their line width to Auto (a width of 0, or typing the word for Auto — the field's own placeholder text in the UI's language, or “auto” — in the field), drawing a one-pixel hairline at each band's start like the square grid instead of a filled band, with the gutter alone as the step between the lines. The width field shows “Auto” when there is no width; a stretched grid shows it dim and not editable, its bands dividing the frame. Only the grid's width and count take the word: an auto layout's gap, whose “Auto” is Space between, and a prototype overlay's offset, whose “Auto” is no offset, keep their own meaning of 0. The align menu no longer forces a 60 px band on a zero width (it keeps Auto); only stale sub-pixel widths are lifted. A saved non-stretched grid with a width of 0 now draws hairlines where it drew 60 px bands, and snaps to one line per band instead of two edges. Regression tests in the layout and field suites. Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 38824149..33120404 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1190,8 +1190,9 @@ pub enum GridAlign { pub struct LayoutGrid { pub kind: GridKind, /// Grid: cell size. Fixed (not stretched) columns/rows: their width/height. 0 (or Auto) draws - /// a one-pixel hairline like the square grid; a positive value draws a band that many - /// document units wide. + /// a one-pixel hairline at each band's start, like the square grid, and the bands step by + /// `gutter` alone (there is no width to add); a positive value draws a band that many document + /// units wide. A stretched grid works its bands out from the frame and ignores `size`. pub size: f64, /// Columns/rows: how many (0: Auto, as many fixed-size ones as fit; a stretched grid has 1). pub count: u32, diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 3adeaf33..a8376363 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1047,15 +1047,17 @@ pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec { let total = if cols { w } else { h }; let gutter = if g.gutter.is_finite() { g.gutter.max(0.0) } else { 0.0 }; let offset = if g.offset.is_finite() { g.offset.max(0.0) } else { 0.0 }; - // Auto width (size 0, a non-stretched grid) draws a one-pixel hairline down the - // band's middle, like the square grid; otherwise a filled band of `size`. + // Auto width (size 0, a non-stretched grid) draws a one-pixel hairline at the band's + // start, like the square grid; otherwise a filled band of `size`. Auto has no width of + // its own, so the gutter alone is the step between the lines. let auto = g.size <= 0.0 && g.align != GridAlign::Stretch; + let fixed = if g.size.is_finite() && g.size > 0.0 { g.size } else { 0.0 }; let count = match g.count { // Auto: as many fixed-size columns (rows) as fit in the room the type leaves them. 0 if g.align != GridAlign::Stretch => { - let size = if g.size.is_finite() && g.size > 0.0 { g.size } else { 60.0 }; let room = if g.align == GridAlign::Center { total } else { total - offset }; - ((room + gutter) / (size + gutter)).floor().clamp(1.0, 500.0) as usize + let step = fixed + gutter; + if step > 0.0 { ((room + gutter) / step).floor().clamp(1.0, 500.0) as usize } else { 1 } } n => n.clamp(1, 500) as usize, }; @@ -1065,7 +1067,7 @@ pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec { (offset, size) } _ => { - let size = if g.size.is_finite() && g.size > 0.0 { g.size } else { 60.0 }; + let size = fixed; let span = size * count as f64 + gutter * (count - 1) as f64; let start = match g.align { GridAlign::Min => offset, @@ -1078,9 +1080,8 @@ pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec { for i in 0..count { let a = start + i as f64 * (size + gutter); if auto { - // A one-pixel hairline down the band's middle (the square grid's lines). - let c = a + size / 2.0; - out.push(if cols { Rect::new(c, 0.0, c, h) } else { Rect::new(0.0, c, w, c) }); + // A one-pixel hairline where the band would start (the square grid's lines). + out.push(if cols { Rect::new(a, 0.0, a, h) } else { Rect::new(0.0, a, w, a) }); } else { out.push(if cols { Rect::new(a, 0.0, a + size, h) } else { Rect::new(0.0, a, w, a + size) }); } @@ -1113,11 +1114,16 @@ pub fn grid_snap_lines_in(doc: &Document, frame: NodeId, xf: Affine) -> (Vec { xs.push(a.x); - xs.push(b.x); + // An Auto-width band is one line, not an edge pair. + if (b.x - a.x).abs() > 1e-9 { + xs.push(b.x); + } } GridKind::Rows => { ys.push(a.y); - ys.push(b.y); + if (b.y - a.y).abs() > 1e-9 { + ys.push(b.y); + } } GridKind::Grid => { if (r.x0 - r.x1).abs() < 1e-9 { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 81e0500c..cf8e944a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -451,13 +451,21 @@ fn grid_hostile_values() { #[test] fn grid_lines_auto_width_columns_rows_hairline() { // Columns with Auto width (size 0, non-stretched) draw one-pixel hairlines at each band's - // middle, like the square grid; a set width draws filled bands. + // start, like the square grid; a set width draws filled bands. let g = LayoutGrid { kind: GridKind::Columns, align: GridAlign::Min, count: 3, size: 0.0, gutter: 10.0, offset: 0.0, ..LayoutGrid::default() }; let lines = grid_lines(&g, 300.0, 50.0); assert_eq!(lines.len(), 3, "three hairlines"); for r in &lines { assert!((r.x0 - r.x1).abs() < 1e-9 && (r.y0 - r.y1).abs() > 1.0, "a vertical hairline: {r:?}"); } + // Auto has no width of its own, so the gutter alone is the step between the lines (no band + // width hidden in it). + let xs: Vec = lines.iter().map(|r| r.x0).collect(); + assert_eq!(xs, [0.0, 10.0, 20.0], "Auto steps by the gutter: {xs:?}"); + // Auto count: as many hairlines as the gutter fits. + let g = LayoutGrid { kind: GridKind::Columns, align: GridAlign::Min, count: 0, size: 0.0, gutter: 10.0, offset: 0.0, ..LayoutGrid::default() }; + let lines = grid_lines(&g, 300.0, 50.0); + assert_eq!(lines.len(), 31, "300 wide, 10 apart: 31 lines"); // A positive width draws filled bands again. let g = LayoutGrid { kind: GridKind::Columns, align: GridAlign::Min, count: 3, size: 8.0, gutter: 10.0, offset: 0.0, ..LayoutGrid::default() }; let lines = grid_lines(&g, 300.0, 50.0); @@ -470,4 +478,10 @@ fn grid_lines_auto_width_columns_rows_hairline() { for r in &lines { assert!((r.y0 - r.y1).abs() < 1e-9 && (r.x0 - r.x1).abs() > 1.0, "a horizontal hairline: {r:?}"); } + // A stretched grid works its bands out from the frame whatever its width says. + let g = + LayoutGrid { kind: GridKind::Columns, align: GridAlign::Stretch, count: 4, size: 0.0, gutter: 10.0, offset: 0.0, ..LayoutGrid::default() }; + let lines = grid_lines(&g, 400.0, 50.0); + assert!(lines.iter().all(|r| (r.x1 - r.x0) > 1.0), "stretch draws bands, not hairlines: {lines:?}"); + assert!((lines[0].x0 - 0.0).abs() < 1e-9 && (lines[3].x1 - 400.0).abs() < 1e-9, "the bands fill the frame"); } diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 9ddc48e5..1f9b1e5d 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -549,3 +549,79 @@ fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() { assert!(field.contains_rect(*l) && field.contains_rect(*v), "both inside the field {field:?}: {l:?} {v:?}"); assert!(l.max.x < v.min.x, "the label stops before the value: {l:?} {v:?}"); } + +/// The middle of the field with response `id`. +fn at(u: &Ui, id: Id) -> Pos2 { + u.ctx.read_response(id.with("field")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center() +} + +/// Type `text` into the field whose response is `id` and press Enter. +fn type_at(u: &mut Ui, id: Id, text: &str) { + let p = at(u, id); + click(u, p); + u.frame(vec![Event::Text(text.into())]); + key(u, Key::Enter, Modifiers::NONE); + u.frame(vec![]); +} + +/// A frame with one layout grid, its settings open in the Design panel. +fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()); + s.execute("node.set", &json!({"ids": [f], "props": {"grids": [grid]}})).unwrap(); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 0usize)), true)); + u.frame(vec![]); + (u, f) +} + +/// Scroll the Design panel down: the grid settings sit below the fold on the test screen. +fn scroll_panel(u: &mut Ui) { + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -300.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }; + for _ in 0..4 { + u.frame(vec![Event::PointerMoved(pos2(1300.0, 600.0)), wheel.clone()]); + } + for _ in 0..5 { + u.frame(vec![]); + } +} + +/// The word for Auto types 0 only where 0 *is* the automatic value: a layout grid's width and +/// count do, an auto layout's gap (Auto there is "Space between") does not. Keying it on the +/// field's placeholder said 0 everywhere, which turned the gap's mode off. +#[test] +fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { + let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "min", "count": 3, "size": 8, "gutter": 10})); + let size = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().grids[0].size; + assert_eq!(size(&u), 8.0); + scroll_panel(&mut u); + type_at(&mut u, Id::new(("grid_field", "size", 0usize)), "Auto"); + assert_eq!(size(&u), 0.0, "a grid's width: Auto is 0"); + type_at(&mut u, Id::new(("grid_field", "size", 0usize)), "6"); + assert_eq!(size(&u), 6.0, "and a number is that width"); + // A stretched grid works its width out from the frame: the field shows Auto and takes no edit. + let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "stretch", "count": 3, "size": 0})); + scroll_panel(&mut u); + assert!(u.ctx.read_response(Id::new(("grid_field", "size", 0usize)).with("field")).is_some(), "the width row is shown"); + type_at(&mut u, Id::new(("grid_field", "size", 0usize)), "40"); + assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].size, 0.0, "a stretched grid's width isn't set"); + + // The auto layout's gap: its placeholder says Auto, but Auto is a mode, not 0. + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()); + s.execute("layout.add", &json!({"ids": [f], "direction": "vertical"})).unwrap(); + s.execute("node.set", &json!({"ids": [f], "props": {"mainAlign": "spaceBetween"}})).unwrap(); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + let gap = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().gap; + let before = gap(&u); + type_at(&mut u, Id::new("al_gap"), "Auto"); + assert_eq!(gap(&u), before, "the word isn't a value where Auto is a mode"); +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 85af66c5..c0a1903b 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -924,7 +924,20 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: let w = ((ui.available_width() - 6.0) / 2.0).max(36.0); for (i, (name, key)) in [("X", "proto_ov_x"), ("Y", "proto_ov_y")].into_iter().enumerate() { let shown = offset.and_then(|o| o.get(i).copied()); - let Some(e) = widgets::number_field_bindable(ui, slot.id(key), Lead::Text(name), shown, "", w, 1.0, None, tr("Auto")).0 + // "Auto" is no offset (right below the layer), not 0: the word isn't taken + // as a value here. + let Some(e) = widgets::number_field_bindable( + ui, + slot.id(key), + Lead::Text(name), + shown, + "", + w, + 1.0, + None, + widgets::Empty::Text(tr("Auto")), + ) + .0 else { continue; }; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index f433a48a..e5ec55ef 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -2056,8 +2056,7 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr // 0 is Auto for fixed-size columns (rows); a stretched grid has at least one. let count = (!auto_count(g)).then(|| f64::from(g.count.max(1))); let key = format!("layoutGrids/{i}/count"); - let e = - crate::variable_ui::number_field_or(app, ui, ids, &key, Id::new(("grid_field", "count", i)), count, col, tr("Auto"), Lead::None, ""); + let e = crate::variable_ui::number_field_auto(app, ui, ids, &key, Id::new(("grid_field", "count", i)), count, col, Lead::None, ""); app.field_run(e, "node.set", |v| { set(&|x| x.count = if x.align == GridAlign::Stretch { v.round().clamp(1.0, 1000.0) } else { v.round().clamp(0.0, 1000.0) } as u32) }); @@ -2069,8 +2068,9 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr menu_popup(&resp).show(|ui| { for (k, (name, a)) in names.iter().zip(aligns).enumerate() { if menu_check(ui, k == cur, name, "") { - // Keep Auto (size 0); only lift stale sub-pixel sizes (0 < size < 1) to a - // sensible band, so a width typed as 0 stays Auto. + // Auto (size 0) stays Auto whatever the alignment: a stretched grid works + // its bands out from the frame, and a fixed one draws hairlines. Only a + // stale sub-pixel width (older files) is lifted to a visible band. app.run( "node.set", set(&|x| { @@ -2089,27 +2089,53 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr }); }); let span = if cols { tr("Width") } else { tr("Height") }; + // 0 is Auto: no band, a one-pixel hairline at each band's start. A stretched grid works + // its bands out from the frame instead, so its width is not set at all. + let span_value = (g.size > 0.0).then_some(g.size); match g.align { GridAlign::Stretch => { - widgets::sublabels(ui, tr("Margin"), Some(tr("Gutter"))); + widgets::sublabels(ui, span, Some(tr("Margin"))); row(ui, 32.0, |ui| { + // The frame, divided by the count: shown as Auto and not editable. + widgets::number_field_disabled(ui, Id::new(("grid_field", "size", i)), Lead::Named(span), tr("Auto"), col); field(app, ui, "offset", g.offset, &offset); - field(app, ui, "gutter", g.gutter, &gutter); }); + widgets::sublabels(ui, tr("Gutter"), None); + row(ui, 32.0, |ui| field(app, ui, "gutter", g.gutter, &gutter)); } GridAlign::Center => { widgets::sublabels(ui, span, Some(tr("Gutter"))); row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field_or(app, ui, ids, &format!("layoutGrids/{i}/size"), Id::new(("grid_field", "size", i)), Some(g.size), col, tr("Auto"), Lead::None, ""); - app.field_run(e, "node.set", |v| set(&|x| x.size = v.max(0.0))); + let we = crate::variable_ui::number_field_auto( + app, + ui, + ids, + &format!("layoutGrids/{i}/size"), + Id::new(("grid_field", "size", i)), + span_value, + col, + Lead::None, + "", + ); + app.field_run(we, "node.set", |v| set(&|x| x.size = v.max(0.0))); field(app, ui, "gutter", g.gutter, &gutter); }); } GridAlign::Min | GridAlign::Max => { widgets::sublabels(ui, span, Some(tr("Offset"))); row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field_or(app, ui, ids, &format!("layoutGrids/{i}/size"), Id::new(("grid_field", "size", i)), Some(g.size), col, tr("Auto"), Lead::None, ""); - app.field_run(e, "node.set", |v| set(&|x| x.size = v.max(0.0))); + let we = crate::variable_ui::number_field_auto( + app, + ui, + ids, + &format!("layoutGrids/{i}/size"), + Id::new(("grid_field", "size", i)), + span_value, + col, + Lead::None, + "", + ); + app.field_run(we, "node.set", |v| set(&|x| x.size = v.max(0.0))); field(app, ui, "offset", g.offset, &offset); }); widgets::sublabels(ui, tr("Gutter"), None); diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index e6cc6860..7f6ff6a4 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -17,7 +17,7 @@ use crate::a11y::Tip; use crate::i18n::tr; use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; -use crate::widgets::{self, BindEvent, Edit, FieldEdit, Lead}; +use crate::widgets::{self, BindEvent, Edit, Empty, FieldEdit, Lead}; /// What picking a variable does. #[derive(Clone)] @@ -258,7 +258,23 @@ pub fn number_field_or( lead: Lead, suffix: &str, ) -> Option { - number_field_edit(app, ui, ids, field, id, value, width, empty, lead, suffix)?.settled() + number_field_edit(app, ui, ids, field, id, value, width, Empty::Text(empty), lead, suffix)?.settled() +} + +/// [`number_field_or`] for a field where 0 is its automatic value (a layout grid's width or +/// count): typing the word for Auto sets 0. See [`widgets::number_field_auto`]. +pub fn number_field_auto( + app: &mut FigApp, + ui: &mut Ui, + ids: &[NodeId], + field: &str, + id: Id, + value: Option, + width: f32, + lead: Lead, + suffix: &str, +) -> Option { + number_field_edit(app, ui, ids, field, id, value, width, Empty::Auto, lead, suffix)?.settled() } /// [`number_field`] over several layers: a relative edit of mixed values comes back as one for @@ -274,7 +290,7 @@ pub fn number_field_mixed( suffix: &str, width: f32, ) -> Option { - number_field_edit(app, ui, ids, field, id, value, width, tr("Mixed"), lead, suffix) + number_field_edit(app, ui, ids, field, id, value, width, Empty::Text(tr("Mixed")), lead, suffix) } fn number_field_edit( @@ -285,7 +301,7 @@ fn number_field_edit( id: Id, value: Option, width: f32, - empty: &str, + empty: Empty<'_>, lead: Lead, suffix: &str, ) -> Option { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 294bd36c..c8a28d48 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -37,6 +37,25 @@ impl Lead<'_> { } } +/// What a number field shows when it has no single value, and whether that word types 0. +#[derive(Clone, Copy)] +pub enum Empty<'a> { + /// Dim text that is only a label ("Mixed", or "Auto" where 0 means something else). + Text(&'a str), + /// Auto, in the UI's language: 0 is the field's automatic value, so the word types 0. + Auto, +} + +impl<'a> Empty<'a> { + /// The text shown when the field has no value. + fn text(self) -> &'a str { + match self { + Empty::Text(s) => s, + Empty::Auto => tr("Auto"), + } + } +} + /// Result of a field edit. pub enum FieldEdit { /// Committed a new value (Enter, Tab or focus loss; an arrow key's step). @@ -157,18 +176,54 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: /// [`number_field`] over several layers: without a single value (`value` None, "Mixed") a relative /// edit comes back as one for each layer ([`Edit::Each`]). pub fn number_field_mixed(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: &str, width: f32, step: f64) -> Option { - number_field_bindable(ui, id, lead, value, suffix, width, step, None, tr("Mixed")).0 + number_field_bindable(ui, id, lead, value, suffix, width, step, None, Empty::Text(tr("Mixed"))).0 } /// [`number_field`] that shows `empty` (dim) for no value instead of "Mixed" (e.g. "Auto"): there /// is no value to edit relative to, so `+4` is 4. pub fn number_field_or(ui: &mut Ui, id: Id, value: Option, empty: &str, width: f32, step: f64) -> Option { - number_field_impl(ui, id, Lead::None, value, "", width, step, None, empty).0?.settled() + number_field_impl(ui, id, Lead::None, value, "", width, step, None, Empty::Text(empty)).0?.settled() +} + +/// [`number_field_or`] for a field where 0 *is* its automatic value (a layout grid's width or +/// count): typing the word for Auto — the placeholder's own text in the UI's language, or the +/// English "auto" — sets 0. Only fields that ask for it get this; a field merely showing "Auto" +/// as its placeholder keeps its own meaning of 0. +pub fn number_field_auto(ui: &mut Ui, id: Id, value: Option, width: f32, step: f64) -> Option { + number_field_impl(ui, id, Lead::None, value, "", width, step, None, Empty::Auto).0?.settled() +} + +/// A number field that can't be edited, showing `empty` (dim): for a value the app works out +/// itself (a stretched grid's band width), so the row keeps its shape. +pub fn number_field_disabled(ui: &mut Ui, id: Id, lead: Lead, empty: &str, width: f32) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); + let resp = ui.interact(rect, id.with("field"), Sense::hover()); + a11y::number(&resp, lead.name(), None, "", empty); + ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); + let lead_w = match lead { + Lead::None | Lead::Named(_) => 6.0, + Lead::Text(s) => text_lead_width(ui, s, width), + Lead::Icon(..) => 24.0, + }; + let lead_rect = Rect::from_min_size(rect.min, vec2(lead_w, FIELD_H)); + match lead { + Lead::Text(s) => { + let galley = crate::bidi::name_galley(ui.painter(), s, regular(11.0), t.text_tertiary, lead_w - 12.0); + ui.painter().galley(pos2(lead_rect.min.x + 8.0, rect.center().y - galley.size().y / 2.0), galley, t.text_tertiary); + } + Lead::Icon(i, _) => { + icons::paint(ui.painter(), Rect::from_center_size(lead_rect.center() + vec2(1.0, 0.0), vec2(14.0, 14.0)), i, t.icon_secondary) + } + Lead::None | Lead::Named(_) => {} + } + ui.painter().text(pos2(rect.min.x + lead_w, rect.center().y), Align2::LEFT_CENTER, empty, regular(11.0), t.text_tertiary); } /// [`number_field`] that can bind a variable: `binding` None = not bindable, `Some(None)` = shows /// a variable button on hover, `Some(Some(name))` = bound (shows the name; click to change, the -/// detach button unbinds). No value shows `empty` ("Mixed", "Auto"). +/// detach button unbinds). No value shows `empty` ("Mixed", "Auto"); [`Empty::Auto`] also takes +/// the word for Auto as 0. pub fn number_field_bindable( ui: &mut Ui, id: Id, @@ -178,7 +233,7 @@ pub fn number_field_bindable( width: f32, step: f64, binding: Option>, - empty: &str, + empty: Empty<'_>, ) -> (Option, Option) { number_field_impl(ui, id, lead, value, suffix, width, step, binding, empty) } @@ -192,7 +247,7 @@ fn number_field_impl( width: f32, step: f64, binding: Option>, - empty: &str, + empty: Empty<'_>, ) -> (Option, Option) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); @@ -210,7 +265,7 @@ fn number_field_impl( let editing_id = id.with("editing"); let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); let field_resp = ui.interact(edit_rect, id.with("field"), Sense::click()); - a11y::number(&field_resp, lead.name(), value, suffix, empty); + a11y::number(&field_resp, lead.name(), value, suffix, empty.text()); a11y::ring_around(ui, rect, &field_resp); // The label scrubs with the pointer; the keyboard stops at the value only. let lead_resp = ui.interact(lead_rect, id.with("lead"), Sense::CLICK | Sense::DRAG); @@ -260,12 +315,12 @@ fn number_field_impl( // A relative edit of no single value goes to each layer's own. let set = |v: f64| Edit::All(FieldEdit::Set(v)); let typed = |text: &str| { - // A field whose placeholder is "Auto" (grid width/count, auto-gap, …) also takes the - // word "Auto" (or the Chinese "自动") as input, setting it to 0 — its automatic value. - let auto_token: &str = &*tr("Auto"); - if empty == auto_token { + // A field where 0 is its automatic value takes the word for Auto as input too: the + // placeholder's own text (so it is what the field shows, in the UI's language) or the + // English word. + if let Empty::Auto = empty { let t = text.trim(); - if t.eq_ignore_ascii_case("auto") || t == "自动" { + if t == empty.text() || t.eq_ignore_ascii_case("auto") { return Some(set(0.0)); } } @@ -360,7 +415,7 @@ fn number_field_impl( None => { let s = match value { Some(v) => format!("{}{}", fmt_num(v), suffix), - None => empty.to_string(), + None => empty.text().to_string(), }; let col = if value.is_some() { t.text } else { t.text_secondary }; // A value too long for its room ends in "…", so a cut number never reads as another. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3ea38a0a..7f7c6cb3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -105,7 +105,7 @@ interaction feel not yet compared. | Frames and sections drawn around layers take those fully inside; sections never drop into frames | done | not in auto layout frames | | Resizing a section leaves its contents in place | done | | | Masks: what a mask hides doesn't take clicks; a mask group's box is the mask's | done | | -| Layout grids (Ctrl+G) | done | columns, rows and grid; Auto line width (0 draws a one-pixel hairline); stretch, left, centre, right; Auto count; several per frame; styles; not shown or snapped on a rotated frame; a nested frame's grids snap but only top-level frames draw them | +| Layout grids (Ctrl+G) | done | columns, rows and grid; Auto line width (0 draws a one-pixel hairline at each band's start, spaced by the gutter; a stretched grid computes its width and shows Auto dim); stretch, left, centre, right; Auto count; several per frame; styles; not shown or snapped on a rotated frame; a nested frame's grids snap but only top-level frames draw them | | Gamut warnings for Display P3 files | done | colour picker note; View ▸ Soft-proof sRGB stripes what sRGB can't show (the canvas itself is sRGB) | | Gradient and padding/gap handles on the canvas | done | | | Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots | @@ -172,7 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-10-19 | minor | Layout grids: columns and rows take an Auto line width (0 or “Auto”), drawing a one-pixel hairline down each band's middle; the width field shows “Auto” and typing it clears the width. The align menu keeps a zero width as Auto instead of forcing 60 px. Regression test added. | +| 2026-10-19 | minor | Layout grids: columns and rows take an Auto line width (0, or typing the word for Auto), drawing a one-pixel hairline at each band's start with the gutter as the step; the width field shows “Auto” and typing it clears the width; a stretched grid shows the width dim and computed. The word is taken only by the fields where 0 is Auto (the grid's width and count), not by an auto layout's gap or a prototype overlay's offset. The align menu keeps a zero width as Auto instead of forcing 60 px. Regression tests in the layout and field suites. | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | From cecc2865b04db2c145e5f46d67569bad3e9e4ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E6=A9=A6?= <108113065+yelintong@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:39:29 +0800 Subject: [PATCH 03/16] Layout grids: a stretched grid's count takes no Auto CodeRabbit: typing "Auto" in the count of a stretched grid set 0, which the setter clamped to 1. The count field only takes the word where 0 is Auto (a fixed-size grid); a stretched grid's count is a plain number. --- crates/ui-egui/src/field_tests.rs | 3 +++ crates/ui-egui/src/right.rs | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 1f9b1e5d..9f278a54 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -611,6 +611,9 @@ fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { assert!(u.ctx.read_response(Id::new(("grid_field", "size", 0usize)).with("field")).is_some(), "the width row is shown"); type_at(&mut u, Id::new(("grid_field", "size", 0usize)), "40"); assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].size, 0.0, "a stretched grid's width isn't set"); + // Nor its count: a stretched grid has at least one band, so the word commits nothing. + type_at(&mut u, Id::new(("grid_field", "count", 0usize)), "Auto"); + assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].count, 3, "a stretched grid keeps its count"); // The auto layout's gap: its placeholder says Auto, but Auto is a mode, not 0. let mut s = Session::new(); diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index e5ec55ef..97b3f5ec 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -2056,7 +2056,13 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr // 0 is Auto for fixed-size columns (rows); a stretched grid has at least one. let count = (!auto_count(g)).then(|| f64::from(g.count.max(1))); let key = format!("layoutGrids/{i}/count"); - let e = crate::variable_ui::number_field_auto(app, ui, ids, &key, Id::new(("grid_field", "count", i)), count, col, Lead::None, ""); + let id = Id::new(("grid_field", "count", i)); + // A stretched grid has at least one band, so 0 (Auto) isn't a count it can take. + let e = if g.align == GridAlign::Stretch { + crate::variable_ui::number_field_or(app, ui, ids, &key, id, count, col, "", Lead::None, "") + } else { + crate::variable_ui::number_field_auto(app, ui, ids, &key, id, count, col, Lead::None, "") + }; app.field_run(e, "node.set", |v| { set(&|x| x.count = if x.align == GridAlign::Stretch { v.round().clamp(1.0, 1000.0) } else { v.round().clamp(0.0, 1000.0) } as u32) }); From fbb4a22e01893b815073fc2917c0882058c655d2 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Tue, 20 Oct 2026 01:12:13 +0530 Subject: [PATCH 04/16] fix(collab-net): allow live sessions above 64 MiB and report accurate transport limits (#857) - Configure server write loop in hub.rs with max_server_message so outgoing ServerMsg payloads above 64 MiB (e.g. document snapshots in Welcome messages) are delivered without socket termination. - Set core max_message_bytes in local live session HostConfig to max_server_message. - Map tungstenite Capacity errors in guest read loop to Outcome::Failed with accurate size limits rather than Outcome::Refused which falsely reported 'The host refused'. - Add loopback tests verifying Welcome delivery above client message limits and accurate diagnostics on client capacity overrun. --- crates/collab-net/src/guest.rs | 12 ++++- crates/collab-net/src/hub.rs | 20 ++++---- crates/collab-net/src/link.rs | 2 +- crates/collab-net/src/loopback_tests.rs | 66 +++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index eb899b8d..1cfd784c 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -367,7 +367,17 @@ fn read_loop(mut ws: WebSocket, ctx: &ReaderCtx) { Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)), Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()), Ok(_) => {} - Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()), + Err(tungstenite::Error::Capacity(_)) => { + let max = ctx.cfg.limits.max_server_message; + let limit = if max >= 1 << 20 { + format!("{} MiB", max >> 20) + } else if max >= 1 << 10 { + format!("{} KiB", max >> 10) + } else { + format!("{max} bytes") + }; + break Outcome::Failed(format!("received message exceeded size limit (max {limit})")); + } Err(e) => break Outcome::Retry(e.to_string()), } }; diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 5cf3e519..8ddde6e2 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -860,7 +860,7 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return }; - let cfg = config(limits.max_client_message); + let write_cfg = config(limits.max_server_message); let mut forwarded = None; // The handshake callback's type is tungstenite's (its error is an HTTP response). #[allow(clippy::result_large_err)] @@ -904,14 +904,16 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit max_block: limits.max_queued_block, max_all: limits.max_queued_total, }; - let writer = - match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) { - Ok(Ok(t)) => t, - _ => { - shutdown(&sock); - return; - } - }; + let writer = match sock + .try_clone() + .map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(write_cfg)), &rx, &s))) + { + Ok(Ok(t)) => t, + _ => { + shutdown(&sock); + return; + } + }; let end = |why: Option<&str>| { if let Some(why) = why { refuse(&out, why); diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index a84395aa..66d07477 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -139,7 +139,7 @@ impl LiveLink for NetLink { doc, name: o.name, limits: self.limits, - core: figcraft_collab::Limits::default(), + core: figcraft_collab::Limits { max_message_bytes: self.limits.max_server_message, ..figcraft_collab::Limits::default() }, }; let host = Host::start(cfg, inbox.clone()).map_err(|e| format!("can't listen on {ip}: {e}"))?; let invite = Invite::new(host.addr().to_string(), file, token); diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 32c86bd0..9d86939c 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -642,3 +642,69 @@ fn the_host_removes_someone_and_they_cant_come_back() { host.stop(); assert_eq!(host.connections(), 0); } + +#[test] +fn welcome_messages_larger_than_client_message_limit_are_delivered() { + let mut doc = Document::new("A".repeat(3000)); + doc.repair(); + let probe = Probe::new(); + let limits = NetLimits { max_client_message: 1024, max_server_message: 64 << 10, ..fast_limits() }; + let cfg = HostConfig { + addr: SocketAddr::from(([127, 0, 0, 1], 0)), + any_port_if_taken: false, + file: FILE.into(), + token: TOKEN.into(), + doc, + name: "Hana".into(), + limits, + core: Limits { max_message_bytes: 64 << 10, ..Limits::default() }, + }; + let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); + let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); + + let guest_probe = Probe::new(); + let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); + guest_cfg.limits = limits; + let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); + let mut gp = guest_probe; + gp.wait("welcome", welcomed); + assert!(welcomed(&gp.events)); + host.stop(); +} + +#[test] +fn oversized_server_message_reports_accurate_diagnostic() { + let mut doc = Document::new("B".repeat(3000)); + doc.repair(); + let probe = Probe::new(); + let limits = NetLimits { max_server_message: 64 << 10, ..fast_limits() }; + let cfg = HostConfig { + addr: SocketAddr::from(([127, 0, 0, 1], 0)), + any_port_if_taken: false, + file: FILE.into(), + token: TOKEN.into(), + doc, + name: "Hana".into(), + limits, + core: Limits { max_message_bytes: 64 << 10, ..Limits::default() }, + }; + let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); + let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); + + let guest_probe = Probe::new(); + let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); + guest_cfg.limits = NetLimits { max_server_message: 512, ..fast_limits() }; + let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); + let mut gp = guest_probe; + gp.wait("accurate failure diagnostic", |e| { + e.iter().any(|ev| { + matches!( + ev, + Event::Status(Status::Failed { reason }) + if !reason.contains("The host refused") && reason.contains("received message exceeded size limit (max 512 bytes)") + ) + }) + }); + assert!(!welcomed(&gp.events)); + host.stop(); +} From 227e182f6889e8f42f7527f0fb79731c7d95419c Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Tue, 20 Oct 2026 01:23:09 +0530 Subject: [PATCH 05/16] fix(collab-net): report exact bytes when server message limit is not a whole unit --- crates/collab-net/src/guest.rs | 4 ++-- crates/collab-net/src/loopback_tests.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index 1cfd784c..7a986ea5 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -369,9 +369,9 @@ fn read_loop(mut ws: WebSocket, ctx: &ReaderCtx) { Ok(_) => {} Err(tungstenite::Error::Capacity(_)) => { let max = ctx.cfg.limits.max_server_message; - let limit = if max >= 1 << 20 { + let limit = if max >= 1 << 20 && max.is_multiple_of(1 << 20) { format!("{} MiB", max >> 20) - } else if max >= 1 << 10 { + } else if max >= 1 << 10 && max.is_multiple_of(1 << 10) { format!("{} KiB", max >> 10) } else { format!("{max} bytes") diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 9d86939c..eebda6de 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -693,7 +693,7 @@ fn oversized_server_message_reports_accurate_diagnostic() { let guest_probe = Probe::new(); let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); - guest_cfg.limits = NetLimits { max_server_message: 512, ..fast_limits() }; + guest_cfg.limits = NetLimits { max_server_message: 1536, ..fast_limits() }; let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); let mut gp = guest_probe; gp.wait("accurate failure diagnostic", |e| { @@ -701,7 +701,7 @@ fn oversized_server_message_reports_accurate_diagnostic() { matches!( ev, Event::Status(Status::Failed { reason }) - if !reason.contains("The host refused") && reason.contains("received message exceeded size limit (max 512 bytes)") + if !reason.contains("The host refused") && reason.contains("received message exceeded size limit (max 1536 bytes)") ) }) }); From e8a6033199c6fc6cbe12bccd580d58e63d2b6f2e Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Tue, 20 Oct 2026 01:33:32 +0530 Subject: [PATCH 06/16] test(collab-net): add ignored loopback test exercising welcome delivery above 64 MiB --- crates/collab-net/src/loopback_tests.rs | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index eebda6de..b59f767c 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -708,3 +708,33 @@ fn oversized_server_message_reports_accurate_diagnostic() { assert!(!welcomed(&gp.events)); host.stop(); } + +#[test] +#[ignore = "allocates a document larger than 64 MiB: cargo test -p figcraft-collab-net -- --ignored"] +fn welcome_larger_than_64_mib_is_delivered() { + let mut doc = Document::new("A".repeat((64 << 20) + 4096)); + doc.repair(); + let probe = Probe::new(); + let limits = NetLimits { max_client_message: 1024, max_server_message: 128 << 20, ..fast_limits() }; + let cfg = HostConfig { + addr: SocketAddr::from(([127, 0, 0, 1], 0)), + any_port_if_taken: false, + file: FILE.into(), + token: TOKEN.into(), + doc, + name: "Hana".into(), + limits, + core: Limits { max_message_bytes: 128 << 20, ..Limits::default() }, + }; + let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); + let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); + + let guest_probe = Probe::new(); + let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); + guest_cfg.limits = limits; + let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); + let mut gp = guest_probe; + gp.wait("welcome", welcomed); + assert!(welcomed(&gp.events)); + host.stop(); +} From 6369b6b7a5fa2c073e23cc67328bb48e8a7a92bd Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Tue, 20 Oct 2026 01:43:50 +0530 Subject: [PATCH 07/16] test(collab-net): exercise NetLink host in large welcome regression test --- crates/collab-net/src/loopback_tests.rs | 30 ------------------------- crates/collab-net/src/session_tests.rs | 16 +++++++++++++ 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index b59f767c..eebda6de 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -708,33 +708,3 @@ fn oversized_server_message_reports_accurate_diagnostic() { assert!(!welcomed(&gp.events)); host.stop(); } - -#[test] -#[ignore = "allocates a document larger than 64 MiB: cargo test -p figcraft-collab-net -- --ignored"] -fn welcome_larger_than_64_mib_is_delivered() { - let mut doc = Document::new("A".repeat((64 << 20) + 4096)); - doc.repair(); - let probe = Probe::new(); - let limits = NetLimits { max_client_message: 1024, max_server_message: 128 << 20, ..fast_limits() }; - let cfg = HostConfig { - addr: SocketAddr::from(([127, 0, 0, 1], 0)), - any_port_if_taken: false, - file: FILE.into(), - token: TOKEN.into(), - doc, - name: "Hana".into(), - limits, - core: Limits { max_message_bytes: 128 << 20, ..Limits::default() }, - }; - let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); - let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); - - let guest_probe = Probe::new(); - let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); - guest_cfg.limits = limits; - let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); - let mut gp = guest_probe; - gp.wait("welcome", welcomed); - assert!(welcomed(&gp.events)); - host.stop(); -} diff --git a/crates/collab-net/src/session_tests.rs b/crates/collab-net/src/session_tests.rs index 32920123..de60b211 100644 --- a/crates/collab-net/src/session_tests.rs +++ b/crates/collab-net/src/session_tests.rs @@ -249,3 +249,19 @@ fn cursor_chat_and_spotlight_between_apps() { pump(&mut host, &mut guest, "the spotlight ending", |_, g| g.live().spotlight.is_none()); pump(&mut host, &mut late, "the spotlight ending for the late joiner", |_, l| l.live().spotlight.is_none() && l.live().follow.is_none()); } + +#[test] +#[ignore = "allocates a document larger than 64 MiB: cargo test -p figcraft-collab-net -- --ignored"] +fn welcome_larger_than_64_mib_is_delivered() { + let mut host = session("Hana"); + let mut guest = session("Ada"); + host.doc_mut().unwrap().name = "A".repeat((64 << 20) + 4096); + let r = host.execute("collab.host", &json!({"port": 0})).unwrap(); + assert_eq!(r["status"], "live"); + let link = r["link"].as_str().unwrap().to_string(); + let r = guest.execute("collab.join", &json!({"link": link})).unwrap(); + assert_eq!(r["status"], "connecting"); + pump(&mut host, &mut guest, "the large welcome", |h, g| g.live().status == Status::Live && h.live().peers.len() == 1); + assert_eq!(guest.doc().unwrap().name.len(), (64 << 20) + 4096); + host.execute("collab.stop", &json!({})).unwrap(); +} From 89529b3d0035a3835f6282873243ae02e2985bee Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 04:22:26 +0700 Subject: [PATCH 08/16] #889: drop the hub write-cap and core-limit changes (neither is read) --- crates/collab-net/src/hub.rs | 20 +++++++++----------- crates/collab-net/src/link.rs | 2 +- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 8ddde6e2..5cf3e519 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -860,7 +860,7 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return }; - let write_cfg = config(limits.max_server_message); + let cfg = config(limits.max_client_message); let mut forwarded = None; // The handshake callback's type is tungstenite's (its error is an HTTP response). #[allow(clippy::result_large_err)] @@ -904,16 +904,14 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit max_block: limits.max_queued_block, max_all: limits.max_queued_total, }; - let writer = match sock - .try_clone() - .map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(write_cfg)), &rx, &s))) - { - Ok(Ok(t)) => t, - _ => { - shutdown(&sock); - return; - } - }; + let writer = + match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) { + Ok(Ok(t)) => t, + _ => { + shutdown(&sock); + return; + } + }; let end = |why: Option<&str>| { if let Some(why) = why { refuse(&out, why); diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 66d07477..a84395aa 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -139,7 +139,7 @@ impl LiveLink for NetLink { doc, name: o.name, limits: self.limits, - core: figcraft_collab::Limits { max_message_bytes: self.limits.max_server_message, ..figcraft_collab::Limits::default() }, + core: figcraft_collab::Limits::default(), }; let host = Host::start(cfg, inbox.clone()).map_err(|e| format!("can't listen on {ip}: {e}"))?; let invite = Invite::new(host.addr().to_string(), file, token); From ddde77db0aa282d4db91490f1b1352288691b48f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 04:26:04 +0700 Subject: [PATCH 09/16] M9: release fonts: web build embeds craft-fonts, pin back to the Latin manifest (#885) The browser has no system fonts, and since releases moved to the shared workflow the web build was made without craft-fonts: no font list and no text drawn. The Georgian change also re-pinned craft-fonts to its main, which has no fonts/latin-manifest.txt, so the next release would fail under CRAFT_FONTS_REQUIRED. Pin the Latin commit again until one commit has both (craft-fonts#42); desktop Georgian uses an installed font. --- .github/workflows/release.yml | 8 +++++++- ROADMAP.md | 1 + docs/releasing.md | 14 +++++++++----- docs/translating.md | 5 +++-- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d1723d8..dd2aefdd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,8 +24,14 @@ jobs: with: app-name: figcraft app-title: FigCraft - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + # Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only + # craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. + # Re-pin once one commit has both (storytold/craft-fonts#42). + craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 has-server: true + # The browser has no system fonts: without craft-fonts the web build can't draw text (#885). + # crates/text/build.rs keeps the web build to Inter. + web-craft-fonts: true version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..224bf7af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/docs/releasing.md b/docs/releasing.md index 7e9220ff..07492f6c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -95,13 +95,17 @@ examples from `packaging/server/` (systemd unit, Caddyfile, nginx; the Dockerfil the sources). Linux only for now; elsewhere operators build it with `cargo build --release -p figcraft-server`. -**Fonts.** Every build job checks out [craft-fonts](https://github.com/storytold/craft-fonts) at -`CRAFT_FONTS_REF` (top of `release.yml`) and builds with `CRAFT_FONTS_DIR` and -`CRAFT_FONTS_REQUIRED=1`, so releases embed its fonts and fail rather than ship without them. +**Fonts.** Every build job, the web one included (`web-craft-fonts`), checks out +[craft-fonts](https://github.com/storytold/craft-fonts) at `craft-fonts-ref` (in `release.yml`) and +builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`, so releases embed its fonts and fail +rather than ship without them. The browser has no system fonts, so a web build without craft-fonts +can't draw text. `crates/text/build.rs` reads both `fonts/manifest.txt` and `fonts/latin-manifest.txt`, so the pin must be a commit that has the Latin manifest (today `4391305`, on craft-fonts' -`pr/latin-presentation-fonts` branch; re-pin to `main` once that lands). The web build embeds -only Inter. Packages carry each embedded font's licence as `OFL-.txt` +`pr/latin-presentation-fonts` branch; re-pin to `main` once that lands). That commit has no +Noto Sans Georgian (craft-fonts' main has it but no Latin manifest), so desktop Georgian uses an +installed font; re-pin when one commit has both (craft-fonts#42). The web build embeds only Inter, +Noto Sans Georgian and the Archivo SemiBold wordmark, when the pin has them. Packages carry each embedded font's licence as `OFL-.txt` (`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app). ### macOS diff --git a/docs/translating.md b/docs/translating.md index b7b23d0b..04d9906b 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -66,8 +66,9 @@ something on screen needs it (see `crates/ui-egui/src/cjk.rs`). The web build ha so Japanese, Korean and Chinese appear there only in a build with craft-fonts and `FIGCRAFT_EMBED_CJK=1` (Noto Sans CJK SC, +16 MB); otherwise they are left out of the picker. A Chinese UI puts its Chinese face first so Han takes Simplified Chinese shapes. Georgian is drawn in -craft-fonts' Noto Sans Georgian, one of the built-in faces in `theme::install_fonts`. Web builds -keep it only when built with craft-fonts (`crates/text/build.rs`); the web release isn't, so its +craft-fonts' Noto Sans Georgian, one of the built-in faces in `theme::install_fonts`, or else in an +installed font on the desktop. Web builds keep it only when the craft-fonts they're built with has +it (`crates/text/build.rs`); the commit releases pin doesn't yet (`docs/releasing.md`), so the web picker leaves Georgian out. A new CJK or other non-Latin language also needs its script in `cjk::language`, or a built-in craft-fonts face. From cc4799061e240a17aca12174114bafe70b4d9343 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 04:38:28 +0700 Subject: [PATCH 10/16] #889: say a join is too large in words for people (native and web), translated; keep one test --- crates/collab-net/src/driver.rs | 5 ++- crates/collab-net/src/driver_tests.rs | 11 ++++- crates/collab-net/src/guest.rs | 14 +----- crates/collab-net/src/join.rs | 8 +++- crates/collab-net/src/loopback_tests.rs | 58 +++++-------------------- crates/collab-net/src/session_tests.rs | 16 ------- crates/engine/src/live.rs | 4 ++ crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/i18n_tests.rs | 9 ++++ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/live.rs | 14 ++++-- 12 files changed, 60 insertions(+), 83 deletions(-) diff --git a/crates/collab-net/src/driver.rs b/crates/collab-net/src/driver.rs index b8f6c154..4ebbec15 100644 --- a/crates/collab-net/src/driver.rs +++ b/crates/collab-net/src/driver.rs @@ -16,7 +16,7 @@ use figcraft_engine::live::{Event, Status}; use web_time::Instant; use crate::inbox::Inbox; -use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved}; +use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved}; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; /// A WebSocket the driver opens, writes and closes; what it receives comes back through the @@ -114,6 +114,9 @@ impl Driver { /// A binary message from the host. pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) { let Some(c) = self.conn.as_mut() else { return }; + if bytes.len() > self.cfg.limits.max_server_message { + return self.end(too_large(&self.cfg.limits), now); + } c.last_heard = now; let taken = match message::decode::(bytes, self.cfg.limits.max_server_message) { // The join code's answer: join the file it opens, on a new connection. diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs index ca6563c5..5e395b98 100644 --- a/crates/collab-net/src/driver_tests.rs +++ b/crates/collab-net/src/driver_tests.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg}; use figcraft_collab::{Client, Limits, Outgoing, Server}; use figcraft_doc::{Document, NodeId}; -use figcraft_engine::live::{Event, Status}; +use figcraft_engine::live::{Event, Status, TOO_LARGE_TO_JOIN}; use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; @@ -262,6 +262,15 @@ fn refusals_and_the_end_of_the_session_are_final() { assert!(d.done()); assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused"))); } + // A message over the size limit: the file is too large for this app, not refused by the host. + let inbox = Inbox::new(Arc::new(|| {})); + let mut cfg = GuestConfig::new(invite(), "Ada".into()); + cfg.limits.max_server_message = 2 << 20; + let mut d = Driver::start(Script::default(), cfg, inbox.clone(), t0); + d.opened(t0); + d.message(&vec![0; (2 << 20) + 1], t0); + assert!(d.done()); + assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if *reason == TOO_LARGE_TO_JOIN.replace("{mb}", "2"))); } #[test] diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index 7a986ea5..2e1d5046 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message}; use crate::inbox::Inbox; use crate::invite::{Endpoint, Invite}; pub use crate::join::GuestConfig; -use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved}; +use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved}; use crate::manage; use crate::net::{Duplex, NetLimits, config, open, shutdown}; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; @@ -367,17 +367,7 @@ fn read_loop(mut ws: WebSocket, ctx: &ReaderCtx) { Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)), Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()), Ok(_) => {} - Err(tungstenite::Error::Capacity(_)) => { - let max = ctx.cfg.limits.max_server_message; - let limit = if max >= 1 << 20 && max.is_multiple_of(1 << 20) { - format!("{} MiB", max >> 20) - } else if max >= 1 << 10 && max.is_multiple_of(1 << 10) { - format!("{} KiB", max >> 10) - } else { - format!("{max} bytes") - }; - break Outcome::Failed(format!("received message exceeded size limit (max {limit})")); - } + Err(tungstenite::Error::Capacity(_)) => break too_large(&ctx.cfg.limits), Err(e) => break Outcome::Retry(e.to_string()), } }; diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index 81cd2437..cc3be839 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -7,7 +7,7 @@ use std::time::Duration; use figcraft_collab::Client; use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg}; -use figcraft_engine::live::{Event, Role, SessionInfo, Status}; +use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN}; use crate::inbox::Inbox; use crate::invite::Invite; @@ -95,6 +95,12 @@ pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome { }) } +/// The host sent a message over this app's size limit (the file's welcome, usually): final, and +/// not the host's doing. +pub(crate) fn too_large(limits: &NetLimits) -> Outcome { + Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string())) +} + /// The server's answer to the join code: the invite is whole now. pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> { if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) { diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index eebda6de..a043d8c7 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal}; use figcraft_collab::{Client, Limits}; use figcraft_doc::{Document, NodeId}; -use figcraft_engine::live::{Event, PEOPLE, Status}; +use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN}; use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; @@ -644,11 +644,10 @@ fn the_host_removes_someone_and_they_cant_come_back() { } #[test] -fn welcome_messages_larger_than_client_message_limit_are_delivered() { - let mut doc = Document::new("A".repeat(3000)); +fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { + let mut doc = Document::new("B".repeat(1 << 20)); doc.repair(); let probe = Probe::new(); - let limits = NetLimits { max_client_message: 1024, max_server_message: 64 << 10, ..fast_limits() }; let cfg = HostConfig { addr: SocketAddr::from(([127, 0, 0, 1], 0)), any_port_if_taken: false, @@ -656,55 +655,18 @@ fn welcome_messages_larger_than_client_message_limit_are_delivered() { token: TOKEN.into(), doc, name: "Hana".into(), - limits, - core: Limits { max_message_bytes: 64 << 10, ..Limits::default() }, + limits: fast_limits(), + core: Limits::default(), }; let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); - let guest_probe = Probe::new(); let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); - guest_cfg.limits = limits; - let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); - let mut gp = guest_probe; - gp.wait("welcome", welcomed); - assert!(welcomed(&gp.events)); - host.stop(); -} - -#[test] -fn oversized_server_message_reports_accurate_diagnostic() { - let mut doc = Document::new("B".repeat(3000)); - doc.repair(); - let probe = Probe::new(); - let limits = NetLimits { max_server_message: 64 << 10, ..fast_limits() }; - let cfg = HostConfig { - addr: SocketAddr::from(([127, 0, 0, 1], 0)), - any_port_if_taken: false, - file: FILE.into(), - token: TOKEN.into(), - doc, - name: "Hana".into(), - limits, - core: Limits { max_message_bytes: 64 << 10, ..Limits::default() }, - }; - let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); - let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); - - let guest_probe = Probe::new(); - let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); - guest_cfg.limits = NetLimits { max_server_message: 1536, ..fast_limits() }; - let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); - let mut gp = guest_probe; - gp.wait("accurate failure diagnostic", |e| { - e.iter().any(|ev| { - matches!( - ev, - Event::Status(Status::Failed { reason }) - if !reason.contains("The host refused") && reason.contains("received message exceeded size limit (max 1536 bytes)") - ) - }) - }); + guest_cfg.limits = NetLimits { max_server_message: 1 << 20, ..fast_limits() }; + let mut gp = Probe::new(); + let _g = Guest::start(guest_cfg, gp.inbox.clone()).unwrap(); + let too_large = TOO_LARGE_TO_JOIN.replace("{mb}", "1"); + gp.wait("the file too large to join", |e| e.iter().any(|ev| matches!(ev, Event::Status(Status::Failed { reason }) if *reason == too_large))); assert!(!welcomed(&gp.events)); host.stop(); } diff --git a/crates/collab-net/src/session_tests.rs b/crates/collab-net/src/session_tests.rs index de60b211..32920123 100644 --- a/crates/collab-net/src/session_tests.rs +++ b/crates/collab-net/src/session_tests.rs @@ -249,19 +249,3 @@ fn cursor_chat_and_spotlight_between_apps() { pump(&mut host, &mut guest, "the spotlight ending", |_, g| g.live().spotlight.is_none()); pump(&mut host, &mut late, "the spotlight ending for the late joiner", |_, l| l.live().spotlight.is_none() && l.live().follow.is_none()); } - -#[test] -#[ignore = "allocates a document larger than 64 MiB: cargo test -p figcraft-collab-net -- --ignored"] -fn welcome_larger_than_64_mib_is_delivered() { - let mut host = session("Hana"); - let mut guest = session("Ada"); - host.doc_mut().unwrap().name = "A".repeat((64 << 20) + 4096); - let r = host.execute("collab.host", &json!({"port": 0})).unwrap(); - assert_eq!(r["status"], "live"); - let link = r["link"].as_str().unwrap().to_string(); - let r = guest.execute("collab.join", &json!({"link": link})).unwrap(); - assert_eq!(r["status"], "connecting"); - pump(&mut host, &mut guest, "the large welcome", |h, g| g.live().status == Status::Live && h.live().peers.len() == 1); - assert_eq!(guest.doc().unwrap().name.len(), (64 << 20) + 4096); - host.execute("collab.stop", &json!({})).unwrap(); -} diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index ed13de5f..cd8a0f03 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -86,6 +86,10 @@ pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> { Some((byte(0)?, byte(2)?, byte(4)?)) } +/// Why a join failed when a message from the host is over this app's size limit (`{mb}`: the +/// limit in MB). The UI translates it. +pub const TOO_LARGE_TO_JOIN: &str = "This file is too large to join here (up to {mb} MB)"; + /// The connection to a live session. #[derive(Clone, Debug, Default, PartialEq, Serialize)] #[serde(rename_all = "camelCase", tag = "state")] diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ccf833be..ae566fc8 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1393,6 +1393,7 @@ name = "English" "This condition is edited as an expression" = "This condition is edited as an expression" "This file" = "This file" "This file has unsaved changes" = "This file has unsaved changes" +"This file is too large to join here (up to {mb} MB)" = "This file is too large to join here (up to {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 1187c658..596feda6 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1394,6 +1394,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Esta condición se edita como expresión" "This file" = "Este archivo" "This file has unsaved changes" = "Este archivo tiene cambios sin guardar" +"This file is too large to join here (up to {mb} MB)" = "Este archivo es demasiado grande para unirte aquí (hasta {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo." "This frame is new on the branch." = "Este marco es nuevo en la rama." diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index fcf21fde..046212c1 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -135,6 +135,7 @@ fn dynamic_labels() -> Vec<&'static str> { v.extend(Group::ALL.iter().map(|g| g.label())); v.extend(GESTURES.iter().flat_map(|(_, label, mac, other)| [*label, *mac, *other])); v.extend(DATA_LABELS); + v.push(figcraft_engine::live::TOO_LARGE_TO_JOIN); // The starter templates' names, descriptions and kinds. v.extend(figcraft_engine::templates::TEMPLATES.iter().flat_map(|t| [t.name, t.description, t.kind.label()])); v @@ -327,6 +328,14 @@ fn lookups_fall_back_to_english_and_fill_placeholders() { assert!(matches!(lookup(es, k), Some(x) if std::ptr::eq(x, t))); } +#[test] +fn a_live_failure_the_ui_knows_is_read_back_for_translation() { + use figcraft_engine::live::TOO_LARGE_TO_JOIN; + let reason = TOO_LARGE_TO_JOIN.replace("{mb}", "1024"); + assert!(matches!(crate::live::failure_text(&reason), std::borrow::Cow::Owned(t) if t == reason)); + assert!(matches!(crate::live::failure_text("Couldn't reach the server"), std::borrow::Cow::Borrowed(_))); +} + #[test] fn system_languages_pick_the_first_one_with_a_catalog() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 84aedd6a..8495c30d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1510,7 +1510,7 @@ impl FigApp { // What happened in a live session (the network woke us for it). if self.session.live_poll() { if let Some(m) = self.session.message.take() { - self.toast(m); + self.toast(live::failure_text(&m)); } self.background_done(&ctx); } diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 513060ab..a4baa303 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -13,10 +13,11 @@ //! and it fades a few seconds after the last change (frames are asked for only until then). //! Spotlight: your avatar's menu asks everyone to follow you; a banner says so and stops it. +use std::borrow::Cow; use std::collections::BTreeMap; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2}; -use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Share, Signal, Status, Via, initials, parse_color, presence_page}; +use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Share, Signal, Status, TOO_LARGE_TO_JOIN, Via, initials, parse_color, presence_page}; use serde_json::{Value, json}; use crate::FigApp; @@ -595,7 +596,7 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) { (Status::Reconnecting { attempt }, _) => { (trf("Connection lost, reconnecting (attempt {attempt})…", &[("attempt", attempt)]), t.text_secondary) } - (Status::Failed { .. }, _) => (status.label(), t.danger), + (Status::Failed { reason }, _) => (failure_text(reason).into_owned(), t.danger), _ => (tr(&status.label()).to_string(), t.text_secondary), }; if !line.is_empty() { @@ -695,11 +696,18 @@ pub fn join_field(app: &mut FigApp, ui: &mut Ui, typed: &mut String, id: egui::I pub fn join_status(app: &FigApp, ui: &mut Ui, t: &Tokens, session: SessionId) { match app.session.live.get(session).map(|l| &l.status) { Some(Status::Connecting) => widgets::label(ui, tr("Joining…"), t.text_secondary), - Some(Status::Failed { reason }) => widgets::label(ui, reason, t.danger), + Some(Status::Failed { reason }) => widgets::label(ui, &failure_text(reason), t.danger), _ => {} } } +/// Why a live session failed, in the UI language when it is a reason the UI knows (the transport's +/// English otherwise). +pub(crate) fn failure_text(reason: &str) -> Cow<'_, str> { + let mb = TOO_LARGE_TO_JOIN.split_once("{mb}").and_then(|(before, after)| reason.strip_prefix(before)?.strip_suffix(after)); + mb.map_or(Cow::Borrowed(reason), |mb| Cow::Owned(trf(TOO_LARGE_TO_JOIN, &[("mb", &mb)]))) +} + /// Not in a session: your name, how to share (online or on the local network), start; or join /// one with a code. Everything else is under Advanced. fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { From f99fdea3b64c2d3da3acd92bacc8612b7e487b6e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 04:39:13 +0700 Subject: [PATCH 11/16] #881: leave the progress docs to the maintainer --- ROADMAP.md | 3 +-- docs/ui-parity.md | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b9851508..8a587286 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-10-19 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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 behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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 behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) 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 @@ -316,7 +316,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-19 | minor | Layout grids: columns and rows can set their line width to Auto (a width of 0, or typing the word for Auto — the field's own placeholder text in the UI's language, or “auto” — in the field), drawing a one-pixel hairline at each band's start like the square grid instead of a filled band, with the gutter alone as the step between the lines. The width field shows “Auto” when there is no width; a stretched grid shows it dim and not editable, its bands dividing the frame. Only the grid's width and count take the word: an auto layout's gap, whose “Auto” is Space between, and a prototype overlay's offset, whose “Auto” is no offset, keep their own meaning of 0. The align menu no longer forces a 60 px band on a zero width (it keeps Auto); only stale sub-pixel widths are lifted. A saved non-stretched grid with a width of 0 now draws hairlines where it drew 60 px bands, and snaps to one line per band instead of two edges. Regression tests in the layout and field suites. Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7f7c6cb3..766284f1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -105,7 +105,7 @@ interaction feel not yet compared. | Frames and sections drawn around layers take those fully inside; sections never drop into frames | done | not in auto layout frames | | Resizing a section leaves its contents in place | done | | | Masks: what a mask hides doesn't take clicks; a mask group's box is the mask's | done | | -| Layout grids (Ctrl+G) | done | columns, rows and grid; Auto line width (0 draws a one-pixel hairline at each band's start, spaced by the gutter; a stretched grid computes its width and shows Auto dim); stretch, left, centre, right; Auto count; several per frame; styles; not shown or snapped on a rotated frame; a nested frame's grids snap but only top-level frames draw them | +| Layout grids (Ctrl+G) | done | columns, rows and grid; stretch, left, centre, right; Auto count; several per frame; styles; not shown or snapped on a rotated frame; a nested frame's grids snap but only top-level frames draw them | | Gamut warnings for Display P3 files | done | colour picker note; View ▸ Soft-proof sRGB stripes what sRGB can't show (the canvas itself is sRGB) | | Gradient and padding/gap handles on the canvas | done | | | Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots | @@ -172,7 +172,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-10-19 | minor | Layout grids: columns and rows take an Auto line width (0, or typing the word for Auto), drawing a one-pixel hairline at each band's start with the gutter as the step; the width field shows “Auto” and typing it clears the width; a stretched grid shows the width dim and computed. The word is taken only by the fields where 0 is Auto (the grid's width and count), not by an auto layout's gap or a prototype overlay's offset. The align menu keeps a zero width as Auto instead of forcing 60 px. Regression tests in the layout and field suites. | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | From 309271851fca5fda92a4856d834c79f3fbdcbbb4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 04:39:14 +0700 Subject: [PATCH 12/16] #881: keep fixed grids at width 0 drawing bands as before (no hairlines) --- crates/doc/src/props.rs | 5 +---- crates/layout/src/lib.rs | 27 ++++++------------------- crates/layout/src/tests.rs | 38 ------------------------------------ crates/ui-egui/src/canvas.rs | 7 +------ 4 files changed, 8 insertions(+), 69 deletions(-) diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 33120404..4f6491c1 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1189,10 +1189,7 @@ pub enum GridAlign { #[serde(default)] pub struct LayoutGrid { pub kind: GridKind, - /// Grid: cell size. Fixed (not stretched) columns/rows: their width/height. 0 (or Auto) draws - /// a one-pixel hairline at each band's start, like the square grid, and the bands step by - /// `gutter` alone (there is no width to add); a positive value draws a band that many document - /// units wide. A stretched grid works its bands out from the frame and ignores `size`. + /// Grid: cell size. Fixed (not stretched) columns/rows: their width/height. pub size: f64, /// Columns/rows: how many (0: Auto, as many fixed-size ones as fit; a stretched grid has 1). pub count: u32, diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index a8376363..02e7bdaf 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1047,17 +1047,12 @@ pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec { let total = if cols { w } else { h }; let gutter = if g.gutter.is_finite() { g.gutter.max(0.0) } else { 0.0 }; let offset = if g.offset.is_finite() { g.offset.max(0.0) } else { 0.0 }; - // Auto width (size 0, a non-stretched grid) draws a one-pixel hairline at the band's - // start, like the square grid; otherwise a filled band of `size`. Auto has no width of - // its own, so the gutter alone is the step between the lines. - let auto = g.size <= 0.0 && g.align != GridAlign::Stretch; - let fixed = if g.size.is_finite() && g.size > 0.0 { g.size } else { 0.0 }; let count = match g.count { // Auto: as many fixed-size columns (rows) as fit in the room the type leaves them. 0 if g.align != GridAlign::Stretch => { + let size = if g.size.is_finite() && g.size > 0.0 { g.size } else { 60.0 }; let room = if g.align == GridAlign::Center { total } else { total - offset }; - let step = fixed + gutter; - if step > 0.0 { ((room + gutter) / step).floor().clamp(1.0, 500.0) as usize } else { 1 } + ((room + gutter) / (size + gutter)).floor().clamp(1.0, 500.0) as usize } n => n.clamp(1, 500) as usize, }; @@ -1067,7 +1062,7 @@ pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec { (offset, size) } _ => { - let size = fixed; + let size = if g.size.is_finite() && g.size > 0.0 { g.size } else { 60.0 }; let span = size * count as f64 + gutter * (count - 1) as f64; let start = match g.align { GridAlign::Min => offset, @@ -1079,12 +1074,7 @@ pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec { }; for i in 0..count { let a = start + i as f64 * (size + gutter); - if auto { - // A one-pixel hairline where the band would start (the square grid's lines). - out.push(if cols { Rect::new(a, 0.0, a, h) } else { Rect::new(0.0, a, w, a) }); - } else { - out.push(if cols { Rect::new(a, 0.0, a + size, h) } else { Rect::new(0.0, a, w, a + size) }); - } + out.push(if cols { Rect::new(a, 0.0, a + size, h) } else { Rect::new(0.0, a, w, a + size) }); } } } @@ -1114,16 +1104,11 @@ pub fn grid_snap_lines_in(doc: &Document, frame: NodeId, xf: Affine) -> (Vec { xs.push(a.x); - // An Auto-width band is one line, not an edge pair. - if (b.x - a.x).abs() > 1e-9 { - xs.push(b.x); - } + xs.push(b.x); } GridKind::Rows => { ys.push(a.y); - if (b.y - a.y).abs() > 1e-9 { - ys.push(b.y); - } + ys.push(b.y); } GridKind::Grid => { if (r.x0 - r.x1).abs() < 1e-9 { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index cf8e944a..0af9861b 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -447,41 +447,3 @@ fn grid_hostile_values() { assert!(x.is_finite() && y.is_finite() && w.is_finite() && h.is_finite()); } } - -#[test] -fn grid_lines_auto_width_columns_rows_hairline() { - // Columns with Auto width (size 0, non-stretched) draw one-pixel hairlines at each band's - // start, like the square grid; a set width draws filled bands. - let g = LayoutGrid { kind: GridKind::Columns, align: GridAlign::Min, count: 3, size: 0.0, gutter: 10.0, offset: 0.0, ..LayoutGrid::default() }; - let lines = grid_lines(&g, 300.0, 50.0); - assert_eq!(lines.len(), 3, "three hairlines"); - for r in &lines { - assert!((r.x0 - r.x1).abs() < 1e-9 && (r.y0 - r.y1).abs() > 1.0, "a vertical hairline: {r:?}"); - } - // Auto has no width of its own, so the gutter alone is the step between the lines (no band - // width hidden in it). - let xs: Vec = lines.iter().map(|r| r.x0).collect(); - assert_eq!(xs, [0.0, 10.0, 20.0], "Auto steps by the gutter: {xs:?}"); - // Auto count: as many hairlines as the gutter fits. - let g = LayoutGrid { kind: GridKind::Columns, align: GridAlign::Min, count: 0, size: 0.0, gutter: 10.0, offset: 0.0, ..LayoutGrid::default() }; - let lines = grid_lines(&g, 300.0, 50.0); - assert_eq!(lines.len(), 31, "300 wide, 10 apart: 31 lines"); - // A positive width draws filled bands again. - let g = LayoutGrid { kind: GridKind::Columns, align: GridAlign::Min, count: 3, size: 8.0, gutter: 10.0, offset: 0.0, ..LayoutGrid::default() }; - let lines = grid_lines(&g, 300.0, 50.0); - assert!(lines.iter().all(|r| (r.x1 - r.x0).abs() > 1e-9), "a set width draws bands"); - - // Rows Auto width: horizontal hairlines. - let g = LayoutGrid { kind: GridKind::Rows, align: GridAlign::Min, count: 2, size: 0.0, gutter: 10.0, offset: 0.0, ..LayoutGrid::default() }; - let lines = grid_lines(&g, 50.0, 200.0); - assert_eq!(lines.len(), 2, "two hairlines"); - for r in &lines { - assert!((r.y0 - r.y1).abs() < 1e-9 && (r.x0 - r.x1).abs() > 1.0, "a horizontal hairline: {r:?}"); - } - // A stretched grid works its bands out from the frame whatever its width says. - let g = - LayoutGrid { kind: GridKind::Columns, align: GridAlign::Stretch, count: 4, size: 0.0, gutter: 10.0, offset: 0.0, ..LayoutGrid::default() }; - let lines = grid_lines(&g, 400.0, 50.0); - assert!(lines.iter().all(|r| (r.x1 - r.x0) > 1.0), "stretch draws bands, not hairlines: {lines:?}"); - assert!((lines[0].x0 - 0.0).abs() < 1e-9 && (lines[3].x1 - 400.0).abs() < 1e-9, "the bands fill the frame"); -} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b400e56d..96bd20bf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -923,12 +923,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ let a = w * Point::new(r.x0, r.y0); let b = w * Point::new(r.x1, r.y1); let (sa, sb) = (app.to_screen(a.x, a.y), app.to_screen(b.x, b.y)); - // The square grid and an Auto-width line draw one-pixel hairlines; a filled - // band (a column/row with a set width) is painted as a rectangle. - if g.kind == figcraft_doc::GridKind::Grid - || (r.x0 - r.x1).abs() < 1e-9 - || (r.y0 - r.y1).abs() < 1e-9 - { + if g.kind == figcraft_doc::GridKind::Grid { painter.line_segment([sa, sb], stroke(col, 1.0)); } else { painter.rect_filled(Rect::from_two_pos(sa, sb), CornerRadius::ZERO, col); From a7d064895868a9369ef9217bd985a54b39e7c69c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 04:45:44 +0700 Subject: [PATCH 13/16] #881: fixed grids keep their pixel Width and the Stretch->Left lift to 60; the stretched Auto width reuses the number field (disabled) --- crates/ui-egui/src/right.rs | 40 +++++++------------------------ crates/ui-egui/src/variable_ui.rs | 2 +- crates/ui-egui/src/widgets.rs | 35 --------------------------- 3 files changed, 9 insertions(+), 68 deletions(-) diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index b0e4286d..afa884a8 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -2074,14 +2074,12 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr menu_popup(&resp).show(|ui| { for (k, (name, a)) in names.iter().zip(aligns).enumerate() { if menu_check(ui, k == cur, name, "") { - // Auto (size 0) stays Auto whatever the alignment: a stretched grid works - // its bands out from the frame, and a fixed one draws hairlines. Only a - // stale sub-pixel width (older files) is lifted to a visible band. + // Fixed-size columns need a size of their own. app.run( "node.set", set(&|x| { x.align = a; - if a != GridAlign::Stretch && x.size > 0.0 && x.size < 1.0 { + if a != GridAlign::Stretch && !(x.size.is_finite() && x.size > 0.0) { x.size = 60.0; } if a == GridAlign::Stretch { @@ -2095,15 +2093,15 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr }); }); let span = if cols { tr("Width") } else { tr("Height") }; - // 0 is Auto: no band, a one-pixel hairline at each band's start. A stretched grid works - // its bands out from the frame instead, so its width is not set at all. - let span_value = (g.size > 0.0).then_some(g.size); match g.align { GridAlign::Stretch => { widgets::sublabels(ui, span, Some(tr("Margin"))); row(ui, 32.0, |ui| { // The frame, divided by the count: shown as Auto and not editable. - widgets::number_field_disabled(ui, Id::new(("grid_field", "size", i)), Lead::Named(span), tr("Auto"), col); + ui.add_enabled_ui(false, |ui| { + let auto = widgets::Empty::Text(tr("Auto")); + widgets::number_field_bindable(ui, Id::new(("grid_field", "size", i)), Lead::Named(span), None, "", col, 1.0, None, auto) + }); field(app, ui, "offset", g.offset, &offset); }); widgets::sublabels(ui, tr("Gutter"), None); @@ -2112,36 +2110,14 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr GridAlign::Center => { widgets::sublabels(ui, span, Some(tr("Gutter"))); row(ui, 32.0, |ui| { - let we = crate::variable_ui::number_field_auto( - app, - ui, - ids, - &format!("layoutGrids/{i}/size"), - Id::new(("grid_field", "size", i)), - span_value, - col, - Lead::None, - "", - ); - app.field_run(we, "node.set", |v| set(&|x| x.size = v.max(0.0))); + field(app, ui, "size", g.size, &size); field(app, ui, "gutter", g.gutter, &gutter); }); } GridAlign::Min | GridAlign::Max => { widgets::sublabels(ui, span, Some(tr("Offset"))); row(ui, 32.0, |ui| { - let we = crate::variable_ui::number_field_auto( - app, - ui, - ids, - &format!("layoutGrids/{i}/size"), - Id::new(("grid_field", "size", i)), - span_value, - col, - Lead::None, - "", - ); - app.field_run(we, "node.set", |v| set(&|x| x.size = v.max(0.0))); + field(app, ui, "size", g.size, &size); field(app, ui, "offset", g.offset, &offset); }); widgets::sublabels(ui, tr("Gutter"), None); diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 7f6ff6a4..39c5396e 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -262,7 +262,7 @@ pub fn number_field_or( } /// [`number_field_or`] for a field where 0 is its automatic value (a layout grid's width or -/// count): typing the word for Auto sets 0. See [`widgets::number_field_auto`]. +/// count): typing the word for Auto sets 0 ([`Empty::Auto`]). pub fn number_field_auto( app: &mut FigApp, ui: &mut Ui, diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8b66965f..2d986c64 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -185,41 +185,6 @@ pub fn number_field_or(ui: &mut Ui, id: Id, value: Option, empty: &str, wid number_field_impl(ui, id, Lead::None, value, "", width, step, None, Empty::Text(empty)).0?.settled() } -/// [`number_field_or`] for a field where 0 *is* its automatic value (a layout grid's width or -/// count): typing the word for Auto — the placeholder's own text in the UI's language, or the -/// English "auto" — sets 0. Only fields that ask for it get this; a field merely showing "Auto" -/// as its placeholder keeps its own meaning of 0. -pub fn number_field_auto(ui: &mut Ui, id: Id, value: Option, width: f32, step: f64) -> Option { - number_field_impl(ui, id, Lead::None, value, "", width, step, None, Empty::Auto).0?.settled() -} - -/// A number field that can't be edited, showing `empty` (dim): for a value the app works out -/// itself (a stretched grid's band width), so the row keeps its shape. -pub fn number_field_disabled(ui: &mut Ui, id: Id, lead: Lead, empty: &str, width: f32) { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); - let resp = ui.interact(rect, id.with("field"), Sense::hover()); - a11y::number(&resp, lead.name(), None, "", empty); - ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); - let lead_w = match lead { - Lead::None | Lead::Named(_) => 6.0, - Lead::Text(s) => text_lead_width(ui, s, width), - Lead::Icon(..) => 24.0, - }; - let lead_rect = Rect::from_min_size(rect.min, vec2(lead_w, FIELD_H)); - match lead { - Lead::Text(s) => { - let galley = crate::bidi::name_galley(ui.painter(), s, regular(11.0), t.text_tertiary, lead_w - 12.0); - ui.painter().galley(pos2(lead_rect.min.x + 8.0, rect.center().y - galley.size().y / 2.0), galley, t.text_tertiary); - } - Lead::Icon(i, _) => { - icons::paint(ui.painter(), Rect::from_center_size(lead_rect.center() + vec2(1.0, 0.0), vec2(14.0, 14.0)), i, t.icon_secondary) - } - Lead::None | Lead::Named(_) => {} - } - ui.painter().text(pos2(rect.min.x + lead_w, rect.center().y), Align2::LEFT_CENTER, empty, regular(11.0), t.text_tertiary); -} - /// [`number_field`] that can bind a variable: `binding` None = not bindable, `Some(None)` = shows /// a variable button on hover, `Some(Some(name))` = bound (shows the name; click to change, the /// detach button unbinds). No value shows `empty` ("Mixed", "Auto"); [`Empty::Auto`] also takes From 0f95ce3d5bd9438b209334699ede51f652dfaa6a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 04:45:45 +0700 Subject: [PATCH 14/16] #881: test Auto in a fixed grid's count, scroll to the field instead of a fixed point --- crates/ui-egui/src/field_tests.rs | 48 +++++++++++++++++-------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 9f278a54..fea0be8e 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -576,43 +576,49 @@ fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) { (u, f) } -/// Scroll the Design panel down: the grid settings sit below the fold on the test screen. -fn scroll_panel(u: &mut Ui) { +/// Scroll the Design panel (over its own field `id`) until that field is on the test screen: the +/// grid settings sit below the fold. +fn scroll_to(u: &mut Ui, id: Id) { let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, -300.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, }; - for _ in 0..4 { - u.frame(vec![Event::PointerMoved(pos2(1300.0, 600.0)), wheel.clone()]); - } - for _ in 0..5 { - u.frame(vec![]); + for _ in 0..10 { + let p = at(u, id); + if p.y < 860.0 { + break; + } + u.frame(vec![Event::PointerMoved(pos2(p.x, 500.0)), wheel.clone()]); + // Let the scroll settle. + for _ in 0..30 { + u.frame(vec![]); + } } } -/// The word for Auto types 0 only where 0 *is* the automatic value: a layout grid's width and -/// count do, an auto layout's gap (Auto there is "Space between") does not. Keying it on the -/// field's placeholder said 0 everywhere, which turned the gap's mode off. +/// The word for Auto types 0 only where 0 *is* the automatic value: a fixed layout grid's count +/// does, an auto layout's gap (Auto there is "Space between") does not. Keying it on the field's +/// placeholder said 0 everywhere, which turned the gap's mode off. #[test] fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { + let count_id = Id::new(("grid_field", "count", 0usize)); let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "min", "count": 3, "size": 8, "gutter": 10})); - let size = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().grids[0].size; - assert_eq!(size(&u), 8.0); - scroll_panel(&mut u); - type_at(&mut u, Id::new(("grid_field", "size", 0usize)), "Auto"); - assert_eq!(size(&u), 0.0, "a grid's width: Auto is 0"); - type_at(&mut u, Id::new(("grid_field", "size", 0usize)), "6"); - assert_eq!(size(&u), 6.0, "and a number is that width"); + let count = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().grids[0].count; + scroll_to(&mut u, count_id); + type_at(&mut u, count_id, "Auto"); + assert_eq!(count(&u), 0, "a fixed grid's count: Auto is 0"); + type_at(&mut u, count_id, "4"); + assert_eq!(count(&u), 4, "and a number is that count"); // A stretched grid works its width out from the frame: the field shows Auto and takes no edit. let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "stretch", "count": 3, "size": 0})); - scroll_panel(&mut u); - assert!(u.ctx.read_response(Id::new(("grid_field", "size", 0usize)).with("field")).is_some(), "the width row is shown"); - type_at(&mut u, Id::new(("grid_field", "size", 0usize)), "40"); + let size_id = Id::new(("grid_field", "size", 0usize)); + scroll_to(&mut u, size_id); + type_at(&mut u, size_id, "40"); assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].size, 0.0, "a stretched grid's width isn't set"); // Nor its count: a stretched grid has at least one band, so the word commits nothing. - type_at(&mut u, Id::new(("grid_field", "count", 0usize)), "Auto"); + type_at(&mut u, count_id, "Auto"); assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].count, 3, "a stretched grid keeps its count"); // The auto layout's gap: its placeholder says Auto, but Auto is a mode, not 0. From 5617c9416740163b9251536fecb5951958be5325 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 04:47:15 +0700 Subject: [PATCH 15/16] M1: nudge while measuring; distances follow, handles hide (#894) Alt+arrows were left out of the nudge keys, so with Alt held to measure the arrows did nothing and the distances never changed. The arrows now nudge with Alt held too (Shift: the big step); the hover measurement is recomputed every frame, so the labels follow each nudge. While measuring to another layer the selection's handles and size label hide; both layers keep their outlines. --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 28 +++++++--- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/measure_nudge_tests.rs | 64 +++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 5 +- docs/ui-parity.md | 3 +- 6 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/measure_nudge_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..7bb02075 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 96bd20bf..49163986 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1011,7 +1011,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot())); - size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, label); + size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, Some(label)); } } // Selection. @@ -1035,8 +1035,10 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ // A layer smaller than its handles on screen shows just its outline (they still answer // just outside it). let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX; + // Measuring to another layer (Alt held) shows just the outlines: no handles or size. + let measuring = app.session.tool.overlay.measure_box.is_some(); // Locked layers (selected in the layers panel) can't be resized: no handles either. - let handles = if sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { + let handles = if measuring || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { 0 } else if is_line { 2 @@ -1045,9 +1047,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } else { 4 }; - size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, label); - arc_overlay(app, painter, col); - shape_overlay(app, painter, col); + size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, (!measuring).then_some(label)); + if !measuring { + arc_overlay(app, painter, col); + shape_overlay(app, painter, col); + } if app.session.tool.tool == ToolId::Scale { // The point that stays put: the dragged pivot, else the anchor. let pivot = scale @@ -1185,8 +1189,16 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } /// A transform box (screen corners tl, tr, br, bl): its outline when `outline`, the first `handles` -/// corner handles and, when dimensions are shown, the size `label` under it (its box returned). -fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color32, outline: bool, handles: usize, label: String) -> Option { +/// corner handles and, when dimensions are shown, the size `label` (if any) under it (its box returned). +fn transform_box( + app: &FigApp, + painter: &egui::Painter, + q: [Pos2; 4], + col: Color32, + outline: bool, + handles: usize, + label: Option, +) -> Option { if outline { quad(painter, q, stroke(col, 1.0)); } @@ -1195,7 +1207,7 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color painter.rect_filled(r, CornerRadius::same(1), Color32::WHITE); painter.rect_stroke(r, CornerRadius::same(1), stroke(col, 1.0), StrokeKind::Inside); } - if app.session.prefs.show_dimensions { + if let Some(label) = label.filter(|_| app.session.prefs.show_dimensions) { let bottom = q.iter().map(|p| p.y).fold(f32::MIN, f32::max); let cx = q.iter().map(|p| p.x).sum::() / 4.0; return Some(pill(painter, pos2(cx, bottom + 9.0), Align2::CENTER_TOP, label, col)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 84aedd6a..8a42d6ad 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -147,6 +147,8 @@ mod live_tests; #[cfg(test)] mod live_text_tests; #[cfg(test)] +mod measure_nudge_tests; +#[cfg(test)] mod menu_aim_tests; #[cfg(test)] mod menu_input_tests; diff --git a/crates/ui-egui/src/measure_nudge_tests.rs b/crates/ui-egui/src/measure_nudge_tests.rs new file mode 100644 index 00000000..b4a5a247 --- /dev/null +++ b/crates/ui-egui/src/measure_nudge_tests.rs @@ -0,0 +1,64 @@ +//! Nudging while measuring (#894), driven through egui headless: with Alt held over another layer +//! the arrows still nudge the selection (Shift: the big step), the distance follows every nudge, +//! and the selection's handles and size label hide while measuring (its outline stays). + +use egui::{Event, Key, Modifiers, Pos2, Shape}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::flat; +use crate::present_tests::id; + +fn labels(u: &Ui) -> Vec { + u.app.session.tool.overlay.guides.iter().filter_map(|g| g.label.clone()).collect() +} + +fn top(u: &Ui, i: NodeId) -> f64 { + u.app.session.doc().unwrap().world_bounds(i).unwrap().y0 +} + +/// Press and release `key` with `m` held, then a settled frame (the pointer still over the canvas). +fn press(u: &mut Ui, key: Key, m: Modifiers, at: Pos2) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: m }; + u.frame_with(vec![ev(true), ev(false)], m); + u.frame_with(vec![Event::PointerMoved(at)], m); +} + +/// Whether the frame drew corner handles or a text `label`. +fn drew(shapes: &[Shape], label: &str) -> (bool, bool) { + let handle = shapes.iter().any(|s| matches!(s, Shape::Rect(r) if (r.rect.width() - 8.0).abs() < 0.1 && (r.rect.height() - 8.0).abs() < 0.1)); + let text = shapes.iter().any(|s| matches!(s, Shape::Text(t) if t.galley.text() == label)); + (handle, text) +} + +#[test] +fn arrows_nudge_while_measuring_and_the_distance_follows() { + let mut s = Session::new(); + let upper = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 40})).unwrap()); + let lower = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 140, "width": 100, "height": 40})).unwrap()); + s.execute("select.set", &json!({"ids": [lower]})).unwrap(); + let mut u = Ui::new(s); + let over = u.app.to_screen(50.0, 20.0); + let (alt, shift_alt) = (Modifiers { alt: true, ..Modifiers::NONE }, Modifiers { alt: true, shift: true, ..Modifiers::NONE }); + + // Not measuring: the selection shows its handles and size. + let shapes = flat(&u.frame_with(vec![Event::PointerMoved(over)], Modifiers::NONE).shapes); + assert_eq!(drew(&shapes, "100 × 40"), (true, true), "handles and size label show"); + + u.frame_with(vec![Event::PointerMoved(over)], alt); + let shapes = flat(&u.frame_with(vec![Event::PointerMoved(over)], alt).shapes); + assert!(u.app.session.tool.overlay.measure_box.is_some(), "Alt over the other layer measures to it"); + assert!(labels(&u).contains(&"100".to_string()), "the gap: {:?}", labels(&u)); + assert_eq!(drew(&shapes, "100 × 40"), (false, false), "measuring hides the handles and size label"); + + press(&mut u, Key::ArrowUp, alt, over); + assert_eq!(top(&u, lower), 139.0, "Alt+Up nudges by the small step"); + assert!(labels(&u).contains(&"99".to_string()), "the gap follows the nudge: {:?}", labels(&u)); + + press(&mut u, Key::ArrowUp, shift_alt, over); + assert_eq!(top(&u, lower), 129.0, "Shift+Alt+Up nudges by the big step"); + assert!(labels(&u).contains(&"89".to_string()), "the gap follows the big nudge: {:?}", labels(&u)); + assert_eq!(top(&u, upper), 0.0, "the measured layer stays put"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f3197960..c86cfdf1 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -279,7 +279,8 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if app.ui.layers_focus && !m.command && !m.alt && crate::layers::key_nav(app, key, m.shift) { return true; } - // Arrow nudges; with nothing selected the arrows pan the view. + // Arrow nudges, Alt held too (measuring stays on while the selection moves); with nothing + // selected the arrows pan the view. let (dx, dy) = match key { Key::ArrowLeft => (-1.0, 0.0), Key::ArrowRight => (1.0, 0.0), @@ -287,7 +288,7 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re Key::ArrowDown => (0.0, 1.0), _ => (0.0, 0.0), }; - if (dx, dy) != (0.0, 0.0) && !m.alt { + if (dx, dy) != (0.0, 0.0) { if app.session.selection().is_empty() { app.run("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift})); } else if m.command { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 766284f1..5b45eeb3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -64,7 +64,7 @@ interaction feel not yet compared. | Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children | | Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; thresholds not yet compared with Figma's | | Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing | -| Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap | +| Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap; the arrows still nudge while measuring (Shift: the big step) and the distances follow; the selection's handles and size label hide while measuring (`measure_nudge_tests`, #894) | | Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) | | Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | From 05da6b21259dc13271fc5e576e1e9deab685ecf2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 04:54:29 +0700 Subject: [PATCH 16/16] M1: node.get reports layer data (pluginData) (#879) Layer data written with node.set {props: {pluginData}} was saved but no command read it back. node.get (the command, the control method and MCP get_node) and document.inspect now report pluginData as {key: value} when a layer or page has any, so a tool keeping state in a file can read it on its next run. Widget keys are included: the plugin rules only stop plugins writing them, and widget.list already reports that state. --- ROADMAP.md | 1 + crates/engine/src/inspect.rs | 4 ++++ crates/engine/src/lib.rs | 2 ++ crates/engine/src/plugin_data_tests.rs | 30 ++++++++++++++++++++++++++ docs/control-protocol.md | 4 +++- docs/mcp.md | 2 +- docs/plugins.md | 4 +++- docs/target-app-parity.md | 3 ++- 8 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/plugin_data_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..22d49f33 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 88bc015a..a6973f01 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -165,6 +165,10 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { o.insert("operandOperation".into(), json!(op)); } } + // Layer data written with `node.set {props: {pluginData}}` (pages too). + if !n.plugin_data.is_empty() { + o.insert("pluginData".into(), json!(n.plugin_data)); + } match &n.kind { NodeKind::Text(t) => { o.insert("characters".into(), json!(t.characters)); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f55bc1db..0871eebf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1775,6 +1775,8 @@ mod pixel_preview_tests; #[cfg(test)] mod playground_tests; #[cfg(test)] +mod plugin_data_tests; +#[cfg(test)] mod plugin_directory_tests; #[cfg(test)] mod plugin_panels_tests; diff --git a/crates/engine/src/plugin_data_tests.rs b/crates/engine/src/plugin_data_tests.rs new file mode 100644 index 00000000..076786b7 --- /dev/null +++ b/crates/engine/src/plugin_data_tests.rs @@ -0,0 +1,30 @@ +//! Layer data (`pluginData`) reads back through `node.get` and `document.inspect` (#879): what +//! `node.set` writes on a layer or a page comes back as `{key: value}`, a removed key goes, and a +//! layer without any reports none. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use serde_json::json; + +#[test] +fn node_get_reports_the_layer_data_node_set_wrote() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))); + let page = s.ds().unwrap().page; + assert!(ex(&mut s, "node.get", json!({"id": f})).get("pluginData").is_none(), "no data, no field"); + ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"com.example.sync": "{\"v\":1}", "n": 2}}})); + ex(&mut s, "node.set", json!({"ids": [page], "props": {"pluginData": {"com.example.sync": "page"}}})); + let got = ex(&mut s, "node.get", json!({"id": f}))["pluginData"].clone(); + assert_eq!(got, json!({"com.example.sync": "{\"v\":1}", "n": "2"}), "values read back as the strings stored"); + assert_eq!(ex(&mut s, "node.get", json!({"id": page}))["pluginData"], json!({"com.example.sync": "page"}), "pages keep data too"); + assert!(ex(&mut s, "node.get", json!({"id": r})).get("pluginData").is_none(), "other layers report none"); + // The document tree reports it as well (the page and its children). + let tree = crate::inspect::document(&s, 2, false); + assert_eq!(tree["pages"][0]["pluginData"], json!({"com.example.sync": "page"})); + // A null removes a key; undo brings it back. + ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"n": null}}})); + assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"], json!({"com.example.sync": "{\"v\":1}"})); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"]["n"], "2"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 08c08b08..c9ae5331 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -131,7 +131,9 @@ component/instance links, overrides, `slot` (a slot layer's property name), boun `devStatus` (handoff status), `devChanged` (changed since it was marked ready for dev), `annotations` (Dev Mode notes: `[{label, properties, category?}]`), `codeLinks` (a component's links to code: `[{language, importPath, name, template}]`), `devResources` (links for developers, -its own and inherited: `[{name, url, index?, from?}]`). The auto layout props +its own and inherited: `[{name, url, index?, from?}]`), `pluginData` (layer data written with +`node.set {props: {pluginData: {key: value}}}`, pages included: `{key: value}`, values as strings; +only when there is some). The auto layout props that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know). ### `ui.inspect {}` diff --git a/docs/mcp.md b/docs/mcp.md index ca41cc33..dfea95cc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -299,7 +299,7 @@ Or in a project's `.mcp.json`: | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. | | `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). | -| `get_node {id, depth}` | One layer as JSON. | +| `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. | | `select {ids}` | Replace the selection (`[]` deselects). | | `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | diff --git a/docs/plugins.md b/docs/plugins.md index 5867bd25..9b1a8f4e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -460,4 +460,6 @@ import what you need from `figcraft`. vectors, components or variables). - Panels: no images, sliders or free layout inside a panel. - Data in the document: plugins keep data on layers (pages included) with `node.set {props: - {pluginData: {key: value}}}`; there is no separate per-document store. + {pluginData: {key: value}}}` (a `null` value removes a key) and read it back with `node.get`, which + reports `pluginData: {key: value}` (values as strings) when a layer has any; there is no separate + per-document store. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 16a5af69..a1a5bf12 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -111,7 +111,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | -| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | +| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version); layer data written with `node.set {props: {pluginData}}` reads back through `node.get` (#879). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | @@ -638,6 +638,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |