Compare commits

...

5 Commits

Author SHA1 Message Date
bflatastic 3f3338984d M1: restore cfg(test) on the selection suite module after merge 2026-10-11 22:14:30 +07:00
bflatastic b49647b13f Merge remote-tracking branch 'origin/main' into feat/layers-keyboard
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/lib.rs
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-11 22:11:13 +07:00
bflatastic f505cabddb Merge remote-tracking branch 'origin/main' into feat/layers-keyboard
# Conflicts:
#	ROADMAP.md
#	docs/target-app-parity.md
2026-10-11 19:32:58 +07:00
bflatastic 1ca3401ec5 M1: layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, long-list QA
- F2 renames the selected layer (with Cmd/Ctrl+R) from the canvas, a clicked
  row or a row with the keyboard; the row gets the keyboard back when the
  rename ends, and Escape there cancels without leaving the panel.
- Shift+F10 (view.contextMenu) opens the context menu at the focused layer
  row or below the canvas selection; the menu takes the keyboard and gives it
  back when it closes.
- F8 (select.area) draws a selection box for keyboard-only users: arrows move
  it, Shift+arrows size it, Enter selects like a marquee (Shift toggles,
  Cmd/Ctrl nested), Escape or a click drops it; screen readers hear its size
  and keys. Agents pass a box. The marquee's selection rules are shared with
  the pointer marquee.
- Long lists: a 10k-layer test that the revealed row is scrolled to and takes
  the keyboard; the UI bench times expand all, arrow keys, reveal and
  collapse all (at most 1.3 ms a frame in release).
- Docs: parity areas 3 and 6 and totals, ui-parity, mcp.md, ROADMAP.
2026-10-11 19:31:16 +07:00
bflatastic d3ebb39706 M1: stroke styles: each side's stroke weight binds to a number variable
Colour styles already applied to strokes (the stroke section's style picker,
style.apply target stroke, edits following, detach). What was missing for
strokes: the per-side weights as their own bindable fields
(strokeTopWeight/Right/Bottom/Left: node.set keys, STROKE_FLOAT scope,
stroke override group, REST boundVariables import). Setting or binding one
side turns on individual strokes with the other sides at the weight. The
Design panel's per-side fields are bindable, two to a row so a variable's
name fits.
2026-10-11 19:31:02 +07:00
26 changed files with 750 additions and 98 deletions

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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-11 · **Change:** minor (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
@ -17,9 +17,9 @@ start with [`docs/gaps.md`](docs/gaps.md).
| Feature breadth | **~80%** (core editor ~91%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~49%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~46%** | estimated: weekly areas 67.8% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Mainstream practitioner | **~46%** | estimated: weekly areas 68.1% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~173–310 h** one agent | estimated |
| Remaining to full parity | **~321–618 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
| Remaining to full parity | **~320–615 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
### Readiness by audience
@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h).
| Dimension | Ready | Hours | Doc |
|---|---|---|---|
| Features (depth) | 57% (core editor 68%) | 60–115 | [target-app-parity](docs/target-app-parity.md) |
| Features (depth) | 57% (core editor 68%) | 59–112 | [target-app-parity](docs/target-app-parity.md) |
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
| File formats | 55% (`.fig` read best effort, no write) | 8–15 | [file-format-parity](docs/file-format-parity.md) |
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) |
@ -74,10 +74,10 @@ Figma's own languages on the landed localization layer (15–25 h).
|---|---|---|---|---|
| Canvas & navigation | 95% | 72% | 3–6 | [ui-parity](docs/ui-parity.md) |
| Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) |
| Layers panel | 95% | 78% | 2–4 | [ui-parity](docs/ui-parity.md) |
| Layers panel | 98% | 82% | 1–2 | [ui-parity](docs/ui-parity.md) |
| Shapes & vector | 97% | 68% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
| Paint | 85% | 66% | 5–9 | [target-app-parity](docs/target-app-parity.md) |
| Strokes | 90% | 72% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
| Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) |
| Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
| Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) |
| Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) |
@ -195,6 +195,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h |
| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h |
| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h |
| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged |

View File

@ -83,6 +83,13 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}),
spec!("select.inverse", "Select inverse", "{}", false, select_inverse),
spec!(
"select.area",
"Select area",
"{x, y, width, height (page units; a marquee from x, y), add? (toggle like a Shift marquee), deep? (nested layers, like Cmd/Ctrl)} → {selection}; with no box: draw one from the keyboard in the app (arrows move it, Shift+arrows size it, Enter selects, Escape cancels)",
false,
select_area
),
spec!("select.children", "Select children", "{}", false, select_children),
spec!("select.parent", "Select parent", "{}", false, select_parent),
spec!("select.next", "Select next sibling", "{}", false, |s, _| select_sibling(s, 1)),
@ -688,6 +695,29 @@ fn select_inverse(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"selection": ids}))
}
/// `select.area`: what a marquee from (x, y) over the box selects; no box asks the app for one.
fn select_area(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "select.area";
let keys = ["x", "y", "width", "height"];
if keys.iter().all(|k| p.get(*k).is_none()) {
s.request_ui("selectArea", json!({}));
return ok();
}
let [x, y, w, h] = keys.map(|k| num(p, k));
let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad(cmd, "pass x, y, width and height (numbers)")) };
if ![x, y, w, h].iter().all(|v| v.is_finite() && v.abs() < 1e9) {
return Err(bad(cmd, "the box is out of range"));
}
let start = figcraft_geom::Point::new(x, y);
let r = figcraft_geom::Rect::from_points(start, figcraft_geom::Point::new(x + w, y + h));
let add = boolean(p, "add") == Some(true);
let base = if add { s.selection() } else { Vec::new() };
let within = s.marquee_within(start)?;
let sel = s.marquee_selection(r, within, boolean(p, "deep") == Some(true), &base, add)?;
s.set_selection(sel.clone());
Ok(json!({"selection": sel}))
}
fn select_children(s: &mut Session, _p: &Value) -> Result<Value> {
let doc = s.doc()?;
let mut ids = Vec::new();

View File

@ -8,9 +8,12 @@ use serde_json::{Map, Value, json};
use crate::params::*;
use crate::{Result, bad};
/// The per-side stroke weight keys, in `StrokeProps::sides` order (top, right, bottom, left).
pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "strokeBottomWeight", "strokeLeftWeight"];
/// Keys `node.set` understands (documented for agents).
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (parent-relative), width, height, rotation (degrees), \
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], \
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
@ -341,6 +344,17 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
"strokeSides" => {
doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) }
}
"strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => {
let i = STROKE_SIDES.iter().position(|x| *x == k).unwrap_or(0);
let w = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
let st = &mut doc.get_mut(id)?.stroke;
// One side set on its own turns on individual strokes, the others at the weight.
let mut sides = st.sides.unwrap_or([st.weight; 4]);
if let Some(slot) = sides.get_mut(i) {
*slot = w;
}
st.sides = Some(sides);
}
"strokeCap" => {
let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?;
let n = doc.get_mut(id)?;
@ -719,7 +733,7 @@ pub fn override_group(k: &str) -> &'static str {
match k {
"fills" | "fill" => "fills",
"strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides"
| "widthProfile" | "brush" | "strokeBrush" => "strokes",
| "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" | "widthProfile" | "brush" | "strokeBrush" => "strokes",
"effects" => "effects",
"opacity" | "blend" | "blendMode" => "opacity",
"visible" => "visible",

View File

@ -364,7 +364,7 @@ pub fn field_scope(doc: &Document, node: NodeId, field: &str) -> Option<&'static
| "paddingBottom" | "paddingLeft" => "GAP",
"cornerRadius" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "CORNER_RADIUS",
"opacity" => "OPACITY",
"strokeWeight" => "STROKE_FLOAT",
"strokeWeight" | "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => "STROKE_FLOAT",
"fontSize" => "FONT_SIZE",
"lineHeight" => "LINE_HEIGHT",
"letterSpacing" => "LETTER_SPACING",

View File

@ -236,6 +236,13 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("view.nextRegion", "Focus next region", "{}", false, |s, _| region(s, 1)),
spec!("view.previousRegion", "Focus previous region", "{}", false, |s, _| region(s, -1)),
spec!(
"view.contextMenu",
"Show context menu",
"{} the selection's context menu at its layer row (when the layers panel has the keyboard) or on the canvas; the menu takes the keyboard",
false,
|s, _| ui(s, "contextMenu")
),
spec!("view.themeLight", "Light theme", "{}", false, |s, _| {
s.prefs.theme = "light".into();
ok()

View File

@ -208,6 +208,7 @@ pub static KEYS: &[Binding] = &[
k(View, "view.leftSidebar", &["Shift+Cmd+\\"]),
k(View, "view.nextRegion", &["F6"]).label("Focus the next region (toolbar, layers, canvas, properties)").both(),
k(View, "view.previousRegion", &["Shift+F6"]).label("Focus the previous region").both(),
k(View, "view.contextMenu", &["Shift+F10"]).label("Context menu of the selection (the focused layer row or the canvas)"),
k(View, "layers.collapseAll", &["Alt+L"]),
k(View, "doc.nextTab", &["Ctrl+Tab"]),
k(View, "doc.previousTab", &["Ctrl+Shift+Tab"]),
@ -268,7 +269,8 @@ pub static KEYS: &[Binding] = &[
k(Selection, "object.frameSelection", &["Alt+Cmd+G"]),
k(Selection, "object.toggleVisible", &["Shift+Cmd+H"]),
k(Selection, "object.toggleLock", &["Shift+Cmd+L"]),
k(Selection, "object.rename", &["Cmd+R"]),
k(Selection, "object.rename", &["Cmd+R", "F2"]),
k(Selection, "select.area", &["F8"]).label("Select with a box from the keyboard (arrows move it, Shift+arrows size it, Enter selects)"),
// Edit
k(Edit, "edit.undo", &["Cmd+Z"]),
k(Edit, "edit.redo", &["Shift+Cmd+Z"]),

View File

@ -1309,6 +1309,8 @@ mod section_contents_tests;
#[cfg(test)]
mod section_tests;
#[cfg(test)]
mod select_area_tests;
#[cfg(test)]
mod selection_behaviour_tests;
#[cfg(test)]
mod selection_depth_tests;
@ -1319,6 +1321,8 @@ mod shapebuilder_tests;
#[cfg(test)]
mod slot_tests;
#[cfg(test)]
mod stroke_style_tests;
#[cfg(test)]
mod style_tests;
#[cfg(test)]
mod svg_import_tests;

View File

@ -0,0 +1,78 @@
//! `select.area`: what a marquee over a box selects, without a pointer (keyboard box selection in
//! the app, agents over MCP): top-level layers, a frame only when fully covered, the children of
//! the frame the box starts in, Shift-like toggling, Cmd/Ctrl-like nested layers; no box asks the
//! app to draw one.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::NodeId;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn rect(s: &mut Session, x: f64, y: f64, parent: Option<NodeId>) -> NodeId {
id_of(&ex(s, "node.create", json!({"type": "rectangle", "parent": parent, "x": x, "y": y, "width": 20, "height": 20}))["id"]).unwrap()
}
fn area(s: &mut Session, x: f64, y: f64, w: f64, h: f64, extra: Value) -> Vec<NodeId> {
let mut p = json!({"x": x, "y": y, "width": w, "height": h});
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
p.extend(e.clone());
}
let out = ex(s, "select.area", p);
let listed: Vec<NodeId> = out["selection"].as_array().unwrap().iter().filter_map(id_of).collect();
assert_eq!(listed, s.selection(), "the result lists the new selection");
sorted(listed)
}
fn sorted(mut v: Vec<NodeId>) -> Vec<NodeId> {
v.sort();
v
}
#[test]
fn a_box_selects_like_a_marquee() {
let mut s = Session::new();
let frame = id_of(&ex(&mut s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 200}))["id"]).unwrap();
let inner = rect(&mut s, 20.0, 20.0, Some(frame));
let a = rect(&mut s, 0.0, 0.0, None);
let b = rect(&mut s, 100.0, 0.0, None);
// Top-level layers it touches; a frame only touched from outside isn't selected.
assert_eq!(area(&mut s, -10.0, -10.0, 340.0, 40.0, json!({})), sorted(vec![a, b]));
// A negative size draws the box the other way.
assert_eq!(area(&mut s, 130.0, 30.0, -140.0, -40.0, json!({})), sorted(vec![a, b]));
// Covering the frame selects it.
assert_eq!(area(&mut s, 290.0, -10.0, 220.0, 220.0, json!({})), vec![frame]);
// Starting inside the frame selects its children.
assert_eq!(area(&mut s, 305.0, 5.0, 60.0, 60.0, json!({})), vec![inner]);
// Deep: nested layers too.
assert_eq!(area(&mut s, -10.0, -10.0, 340.0, 40.0, json!({"deep": true})), sorted(vec![a, b, inner]));
// Add toggles: what was selected and is covered again comes out.
ex(&mut s, "select.set", json!({"ids": [a]}));
assert_eq!(area(&mut s, 90.0, -10.0, 40.0, 40.0, json!({"add": true})), sorted(vec![a, b]));
assert_eq!(area(&mut s, -10.0, -10.0, 40.0, 40.0, json!({"add": true})), vec![b]);
// An empty spot clears the selection (without add).
assert_eq!(area(&mut s, 1000.0, 1000.0, 10.0, 10.0, json!({})), Vec::<NodeId>::new());
}
#[test]
fn no_box_asks_the_app_for_one_and_bad_boxes_are_refused() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, None);
ex(&mut s, "select.set", json!({"ids": [a]}));
s.ui_requests.clear();
ex(&mut s, "select.area", json!({}));
assert!(s.ui_requests.iter().any(|r| r.kind == "selectArea"));
assert_eq!(s.selection(), vec![a], "nothing selected yet");
for bad in [
json!({"x": 0, "y": 0, "width": 10}),
json!({"x": 0, "y": 0, "width": "wide", "height": 1}),
json!({"x": 1e300, "y": 0, "width": 1, "height": 1}),
] {
assert!(s.execute("select.area", &bad).is_err(), "{bad}");
}
assert_eq!(s.selection(), vec![a]);
}

View File

@ -0,0 +1,77 @@
//! Styles and variables on strokes: a colour style applied to a layer's strokes (linked, following
//! edits of the style, one undo step, detached), and stroke weights, each side on its own too,
//! bound to number variables that follow the frame's mode.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_color::Color;
use figcraft_doc::NodeId;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn node(s: &Session, id: NodeId) -> figcraft_doc::Node {
s.doc().unwrap().node(id).unwrap().clone()
}
fn stroke_hex(s: &Session, id: NodeId) -> Option<String> {
node(s, id).strokes.first().and_then(|p| p.solid_color()).map(Color::to_hex)
}
#[test]
fn a_colour_style_on_strokes_links_follows_edits_and_detaches() {
let mut s = Session::new();
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
let fill = node(&s, r).fills;
let st = ex(&mut s, "style.create", json!({"type": "paint", "name": "Brand/Line", "value": ["#ff0000"]}));
let st = st["id"].as_str().unwrap().to_string();
ex(&mut s, "style.apply", json!({"ids": [r], "style": "Brand/Line", "target": "stroke"}));
assert_eq!(node(&s, r).styles.stroke.as_deref(), Some(st.as_str()));
assert!(node(&s, r).styles.fill.is_none());
assert_eq!(node(&s, r).fills, fill, "the fill keeps its own paint");
let red = stroke_hex(&s, r);
assert!(red.as_deref().is_some_and(|h| h.eq_ignore_ascii_case("FF0000")), "{red:?}");
// One undo step takes the style off again.
ex(&mut s, "edit.undo", json!({}));
assert!(node(&s, r).styles.stroke.is_none());
ex(&mut s, "edit.redo", json!({}));
ex(&mut s, "style.update", json!({"style": st, "value": ["#0000ff"]}));
let blue = stroke_hex(&s, r);
assert!(blue.as_deref().is_some_and(|h| h.eq_ignore_ascii_case("0000FF")), "linked strokes follow the style: {blue:?}");
ex(&mut s, "style.detach", json!({"ids": [r], "which": "stroke"}));
assert!(node(&s, r).styles.stroke.is_none());
assert_eq!(stroke_hex(&s, r), blue, "detaching keeps the colour");
}
#[test]
fn stroke_weights_and_each_side_bind_to_number_variables() {
let mut s = Session::new();
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200}))["id"]).unwrap();
let r = id_of(&ex(&mut s, "node.create", json!({"type": "rectangle", "parent": f, "x": 10, "y": 10, "width": 50, "height": 50}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"], "strokeWeight": 2}}));
let c = ex(&mut s, "variable.createCollection", json!({"name": "Borders", "modes": ["Thin", "Thick"]}));
let c = c["id"].as_str().unwrap().to_string();
ex(&mut s, "variable.create", json!({"collection": c, "name": "border/top", "type": "number", "values": {"Thin": 1, "Thick": 6}}));
ex(&mut s, "variable.create", json!({"collection": c, "name": "border/all", "type": "number", "values": {"Thin": 3, "Thick": 5}}));
// One side: individual strokes turn on, the other sides keep the weight.
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"}));
assert_eq!(node(&s, r).stroke.sides, Some([1.0, 2.0, 2.0, 2.0]));
ex(&mut s, "variable.setMode", json!({"ids": [f], "collection": c, "mode": "Thick"}));
assert_eq!(node(&s, r).stroke.sides, Some([6.0, 2.0, 2.0, 2.0]), "the frame's mode picks the value");
// The whole weight binds too; per-side keys set one side.
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeWeight", "variable": "border/all"}));
assert_eq!(node(&s, r).stroke.weight, 5.0);
ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokeLeftWeight": 4}}));
assert_eq!(node(&s, r).stroke.sides.map(|x| x[3]), Some(4.0));
assert!(s.execute("node.set", &json!({"ids": [r], "props": {"strokeRightWeight": "thick"}})).is_err());
// Only stroke-weight variables are offered for these fields, and instances count them as
// stroke overrides.
let doc = s.doc().unwrap();
for field in crate::cmd::props::STROKE_SIDES.into_iter().chain(["strokeWeight"]) {
assert_eq!(crate::cmd::variables::field_scope(doc, r, field), Some("STROKE_FLOAT"), "{field}");
assert_eq!(crate::cmd::props::override_group(field), "strokes", "{field}");
}
}

View File

@ -717,6 +717,41 @@ pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, anchor: (
}
impl Session {
/// What a marquee starting at page point `p` selects inside: the top-level frame it starts in
/// (its children), or the focus view's frame.
pub(crate) fn marquee_within(&self, p: Point) -> Result<Option<NodeId>> {
let (doc, page) = (self.doc()?, self.page()?);
let within = frame_at(doc, page, p, &[]).filter(|f| doc.node(*f).is_some_and(|n| n.parent == Some(page)));
let within = within.filter(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(p)));
Ok(self.ds()?.focused().or(within))
}
/// The selection a marquee over page rect `r` makes from `base` (what was selected before):
/// the layers it touches (`deep`: nested ones too); `additive` (Shift) toggles them.
pub(crate) fn marquee_selection(&self, r: Rect, within: Option<NodeId>, deep: bool, base: &[NodeId], additive: bool) -> Result<Vec<NodeId>> {
let (doc, page) = (self.doc()?, self.page()?);
let mut hits =
if deep { figcraft_doc::geometry::marquee_deep(doc, page, r) } else { figcraft_doc::geometry::marquee_select(doc, page, r, within) };
if let Some(f) = self.ds()?.focused() {
hits.retain(|h| *h != f && doc.is_ancestor(f, *h));
}
// A marquee over a frame from outside selects the frame only when it covers it fully.
if within.is_none() && !deep {
hits.retain(|h| {
let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1)));
full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }))
});
}
// Shift toggles: what was selected and is touched again comes out.
let mut sel: Vec<NodeId> = base.iter().copied().filter(|b| !(additive && hits.contains(b))).collect();
for h in hits {
if !base.contains(&h) && !sel.contains(&h) {
sel.push(h);
}
}
Ok(sel)
}
pub fn set_tool(&mut self, t: ToolId) {
if self.tool.tool == ToolId::Pen && t != ToolId::Pen {
let _ = self.pen_finish();
@ -939,11 +974,7 @@ impl Session {
self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default };
}
None => {
// Marquee; starting inside a top-level frame selects its children.
let within = frame_at(&doc, page, ev.p(), &[]).filter(|f| doc.node(*f).is_some_and(|n| n.parent == Some(page)));
let within = within.filter(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(ev.p())));
// The focus view selects only inside its frame.
let within = self.ds()?.focused().or(within);
let within = self.marquee_within(ev.p())?;
if !ev.shift {
self.set_selection(vec![]);
}
@ -960,31 +991,8 @@ impl Session {
Gesture::Marquee { start, additive, base, within } => {
let r = Rect::from_points(*start, ev.p());
self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]);
let doc = self.doc()?.clone();
let page = self.page()?;
// Cmd/Ctrl: the nested layers it touches.
let mut hits = if ev.cmd {
figcraft_doc::geometry::marquee_deep(&doc, page, r)
} else {
figcraft_doc::geometry::marquee_select(&doc, page, r, *within)
};
if let Some(f) = self.ds()?.focused() {
hits.retain(|h| *h != f && doc.is_ancestor(f, *h));
}
// A marquee over a frame from outside selects the frame only when it covers it fully.
if within.is_none() && !ev.cmd {
hits.retain(|h| {
let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1)));
full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }))
});
}
// Shift toggles: what was selected and is touched again comes out.
let mut sel: Vec<NodeId> = base.iter().copied().filter(|b| !(*additive && hits.contains(b))).collect();
for h in hits {
if !base.contains(&h) && !sel.contains(&h) {
sel.push(h);
}
}
let sel = self.marquee_selection(r, *within, ev.cmd, base, *additive)?;
self.set_selection(sel);
Ok(())
}

View File

@ -326,8 +326,9 @@ fn node_binding<'v>(k: &str, v: &'v Value, n: &Node, out: &mut Vec<(String, &'v
}
"itemSpacing" => push("gap", alias(v)),
"counterAxisSpacing" => push("crossGap", alias(v)),
"characters" | "visible" | "opacity" | "strokeWeight" | "paddingLeft" | "paddingRight" | "paddingTop" | "paddingBottom" | "topLeftRadius"
| "topRightRadius" | "bottomLeftRadius" | "bottomRightRadius" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => push(k, alias(v)),
"characters" | "visible" | "opacity" | "strokeWeight" | "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight"
| "strokeLeftWeight" | "paddingLeft" | "paddingRight" | "paddingTop" | "paddingBottom" | "topLeftRadius" | "topRightRadius"
| "bottomLeftRadius" | "bottomRightRadius" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => push(k, alias(v)),
"rectangleCornerRadii" => {
for (key, f) in [
("RECTANGLE_TOP_LEFT_CORNER_RADIUS", "topLeftRadius"),

View File

@ -979,6 +979,8 @@ name = "English"
"Select points to edit their position, radius and mirroring" = "Select points to edit their position, radius and mirroring"
"Select slot" = "Select slot"
"Selection" = "Selection"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows size it, Enter selects, Escape cancels" = "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows size it, Enter selects, Escape cancels"
"Selection box: arrows move it, Shift+arrows size it, Enter selects, Escape cancels" = "Selection box: arrows move it, Shift+arrows size it, Enter selects, Escape cancels"
"Send backward" = "Send backward"
"Send to back" = "Send to back"
"Server address" = "Server address"

View File

@ -980,6 +980,8 @@ status = "draft — needs native review"
"Select points to edit their position, radius and mirroring" = "Selecciona puntos para editar su posición, radio y reflejo"
"Select slot" = "Seleccionar ranura"
"Selection" = "Selección"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección, {w} por {h} en {x}, {y}: las flechas lo mueven, Mayús+flechas cambian su tamaño, Intro selecciona, Esc cancela"
"Selection box: arrows move it, Shift+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección: las flechas lo mueven, Mayús+flechas cambian su tamaño, Intro selecciona, Esc cancela"
"Send backward" = "Enviar atrás"
"Send to back" = "Enviar al fondo"
"Server address" = "Dirección del servidor"
@ -1598,6 +1600,7 @@ status = "draft — needs native review"
"section.toggleContents" = "Ocultar/mostrar contenido de la sección"
"select.add" = "Añadir a la selección"
"select.all" = "Seleccionar todo"
"select.area" = "Seleccionar área"
"select.byName" = "Seleccionar capas por nombre"
"select.children" = "Seleccionar hijos"
"select.inverse" = "Invertir selección"
@ -1779,6 +1782,7 @@ status = "draft — needs native review"
"version.restore" = "Restaurar versión"
"view.assets" = "Panel de recursos"
"view.comments" = "Comentarios"
"view.contextMenu" = "Mostrar menú contextual"
"view.design" = "Panel de diseño"
"view.designPrototype" = "Alternar entre Diseño y Prototipo"
"view.devMode" = "Modo desarrollo"

View File

@ -327,24 +327,9 @@ fn keys(app: &mut FigApp, ctx: &Context) {
return;
}
// F6 / Shift+F6 (and the commands they run).
let platform = crate::shortcuts::platform();
let region_key = ctx.input(|i| {
i.events.iter().find_map(|e| match e {
Event::Key { key, pressed: true, modifiers, .. } => {
keymap::lookup(platform, keymap::Context::Canvas, key.name(), crate::shortcuts::mods(*modifiers))
.and_then(|b| match b.id {
NEXT_REGION => Some(1),
PREVIOUS_REGION => Some(-1),
_ => None,
})
.map(|step| (step, *key, *modifiers))
}
_ => None,
})
});
if let Some((step, key, m)) = region_key {
if let Some((id, key, m)) = binding_pressed(ctx, &[NEXT_REGION, PREVIOUS_REGION]) {
ctx.input_mut(|i| i.consume_key(m, key));
app.ui.nav.step(step);
app.ui.nav.step(if id == NEXT_REGION { 1 } else { -1 });
}
if let Some(mv) = app.ui.nav.pending.take() {
let to = match mv {
@ -418,6 +403,12 @@ fn keys(app: &mut FigApp, ctx: &Context) {
}
}
Region::Layers if in_region && app.ui.nav.last.row_focused => {
// The canvas shortcuts are off while a row has the keyboard: rename (F2, Cmd/Ctrl+R)
// and the context menu (Shift+F10) work on its layer here.
if let Some((id, key, m)) = binding_pressed(ctx, &["object.rename", "view.contextMenu"]) {
ctx.input_mut(|i| i.consume_key(m, key));
crate::shortcuts::run_binding(app, id);
}
for k in arrows {
let shift = matches!(k, Key::ArrowUp | Key::ArrowDown) && pressed(k, Modifiers::SHIFT);
if (pressed(k, Modifiers::NONE) || shift) && crate::layers::key_nav(app, k, shift) {
@ -429,8 +420,10 @@ fn keys(app: &mut FigApp, ctx: &Context) {
}
_ => {}
}
// Escape gives the keyboard back to the canvas (a popup it closes keeps it where it was).
// Escape gives the keyboard back to the canvas (a popup it closes keeps it where it was; a layer
// rename it cancels gives it back to the row).
if region != Region::Canvas
&& app.ui.rename.is_none()
&& pressed(Key::Escape, Modifiers::NONE)
&& !egui::Popup::is_any_open(ctx)
&& app.ui.nav.opener.is_none()
@ -455,6 +448,63 @@ fn keys(app: &mut FigApp, ctx: &Context) {
}
}
/// The first of the commands `ids` a key pressed this frame is bound to (canvas keys), with the key.
fn binding_pressed(ctx: &Context, ids: &[&str]) -> Option<(&'static str, Key, Modifiers)> {
let platform = crate::shortcuts::platform();
ctx.input(|i| {
i.events.iter().find_map(|e| match e {
Event::Key { key, pressed: true, modifiers, .. } => {
keymap::lookup(platform, keymap::Context::Canvas, key.name(), crate::shortcuts::mods(*modifiers))
.filter(|b| ids.contains(&b.id))
.map(|b| (b.id, *key, *modifiers))
}
_ => None,
})
})
}
/// A layer rename from the keyboard ended: the selected layer's row takes the keyboard again.
pub fn refocus_row(nav: &mut Nav) {
if nav.keyboard {
nav.region = Region::Layers;
nav.refocus = 3;
}
}
/// Open the context menu from the keyboard (Shift+F10, `view.contextMenu`): for the selection, at
/// its layer row when the layers panel has the keyboard, else on the canvas below the selection
/// (the middle of the canvas with nothing selected). The menu takes the keyboard (keyboard mode),
/// and gives it back to the row or the canvas when it closes. Call after the panels drew.
pub fn context_menu(app: &mut FigApp, ctx: &Context) {
let nav = &app.ui.nav;
let rect_of = |id: Id| ctx.viewport(|v| v.this_pass.widgets.get(id).or_else(|| v.prev_pass.widgets.get(id)).map(|w| w.rect));
let row = (app.ui.layers_focus || nav.region == Region::Layers)
.then(|| Region::Layers.of(&nav.next.preferred).or(*Region::Layers.of(&nav.last.preferred)))
.flatten()
.and_then(|id| Some((id, rect_of(id)?)));
let (opener, pos, region) = match row {
Some((id, r)) => (Some(id), egui::pos2(r.min.x + 40.0, r.max.y), Region::Layers),
None => {
let canvas = app.canvas.rect;
let below = app.session.ds().ok().and_then(|ds| {
let b = ds.selection.iter().filter_map(|i| ds.doc.world_bounds(*i)).reduce(|a, b| a.union(b))?;
Some(egui::pos2(app.to_screen(b.x0, b.y1).x, app.to_screen(b.x0, b.y1).y + 8.0))
});
let pos = below.filter(|p| canvas.contains(*p)).unwrap_or(canvas.center());
(nav.next.canvas.or(nav.last.canvas), pos, Region::Canvas)
}
};
app.ui.context_menu = Some((pos, app.session.selection()));
let nav = &mut app.ui.nav;
nav.keyboard = true;
nav.region = region;
nav.opener = None;
if let Some(id) = opener {
ctx.memory_mut(|m| m.request_focus(id));
}
ctx.request_repaint();
}
/// After everything drew: the layer row the arrows moved to takes the keyboard, and the focus ring.
pub fn end(app: &mut FigApp, ctx: &Context) {
// A popup registered before it closed later in the frame (Escape) isn't open any more.

View File

@ -145,7 +145,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
let ctx = ui.ctx().clone();
let ppp = ctx.pixels_per_point();
let resp = ui.interact(rect, ui.id().with("canvas"), Sense::click_and_drag());
crate::a11y::canvas(&mut app.ui.nav, &resp, || describe(&app.session));
let key_box = app.ui.key_box;
crate::a11y::canvas(&mut app.ui.nav, &resp, || key_box.map_or_else(|| describe(&app.session), describe_box));
if let Ok(ds) = app.session.ds_mut()
&& ((ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5)
{
@ -270,6 +271,16 @@ pub fn describe(s: &figcraft_engine::Session) -> String {
}
}
/// What the canvas says while a keyboard selection box shows: its size and place, and the keys.
fn describe_box([x0, y0, x1, y1]: [f64; 4]) -> String {
let n = crate::widgets::fmt_num;
let (w, h, x, y) = (n((x1 - x0).abs()), n((y1 - y0).abs()), n(x0.min(x1)), n(y0.min(y1)));
trf(
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows size it, Enter selects, Escape cancels",
&[("w", &w), ("h", &h), ("x", &x), ("y", &y)],
)
}
/// Pixel preview's resolution (rendered pixels per page pixel) when it's on and zoomed in past it.
fn pixel_preview(ds: &figcraft_engine::DocState) -> Option<f64> {
let k = f64::from(ds.view.pixel_scale.clamp(1, 2));
@ -378,6 +389,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) {
// A press on the canvas gives the keyboard back to it (arrows nudge again).
if resp.hovered() && ctx.input(|i| i.pointer.any_pressed()) {
app.ui.layers_focus = false;
app.ui.key_box = None;
}
// Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans.
if resp.hovered() {
@ -936,7 +948,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
// Smart guides and measurements (their labels kept clear of the size label).
crate::measure_overlay::draw(app, painter, t, size_label);
// Marquee.
if let Some([x0, y0, x1, y1]) = ov.marquee {
if let Some([x0, y0, x1, y1]) = ov.marquee.or(app.ui.key_box) {
let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1));
painter.rect_filled(r, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1));
painter.rect_stroke(r, CornerRadius::ZERO, stroke(ACCENT, 1.0), StrokeKind::Inside);

View File

@ -589,6 +589,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
// go on to the next / previous row.
let mut rename_row: Option<(usize, NodeId, usize)> = None;
let mut rename_next: Option<NodeId> = None;
let renaming = app.ui.rename.is_some();
if let Some(rn) = app.ui.rename {
match rows.iter().position(|r| r.id == rn.id) {
None => app.ui.rename = None,
@ -906,6 +907,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
if let Some(id) = rename_next {
rename_layer(app, id);
}
if renaming && app.ui.rename.is_none() {
a11y::refocus_row(&mut app.ui.nav);
}
if let Some((id, m)) = clicked {
app.ui.layers_focus = true;
let order: Vec<NodeId> = rows.iter().map(|r| r.id).collect();

View File

@ -0,0 +1,197 @@
//! The keyboard alone, driven through egui headless: F2 renames the selected layer (from the
//! canvas, a clicked row or a row with the keyboard, which gets it back afterwards), Shift+F10
//! opens the context menu at the focused row or below the canvas selection and the menu takes the
//! keyboard, and F8 draws a selection box the arrows move and size and Enter selects with.
use egui::{Event, Key, Modifiers};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::a11y::{Region, focused_name};
use crate::layers_drag_tests::Ui;
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn press(u: &mut Ui, key: Key, modifiers: Modifiers) {
u.frame(vec![
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
]);
}
fn settle(u: &mut Ui) {
for _ in 0..3 {
u.frame(vec![]);
}
}
fn name(u: &Ui, id: NodeId) -> String {
u.app.session.doc().unwrap().node(id).unwrap().name.clone()
}
fn renaming(u: &Ui) -> Option<NodeId> {
u.app.ui.rename.map(|r| r.id)
}
/// Two rectangles, "Rect A" and "Rect B" (listed above A: it's on top), A selected.
fn setup() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap());
let b = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 30, "width": 20, "height": 20})).unwrap());
s.execute("object.rename", &json!({"ids": [a], "name": "Rect A"})).unwrap();
s.execute("object.rename", &json!({"ids": [b], "name": "Rect B"})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut u = Ui::new(s);
u.frame(vec![]);
(u, a, b)
}
/// F6 to the layers panel, on the selected layer's row.
fn to_layers(u: &mut Ui) {
for _ in 0..4 {
press(u, Key::F6, Modifiers::NONE);
if u.app.ui.nav.region == Region::Layers {
return;
}
}
panic!("F6 never reached the layers panel");
}
#[test]
fn f2_renames_from_the_canvas_and_from_a_row_with_the_keyboard() {
let (mut u, a, _) = setup();
press(&mut u, Key::F2, Modifiers::NONE);
assert_eq!(renaming(&u), Some(a), "F2 on the canvas renames the selected layer");
u.frame(vec![]);
u.frame(vec![Event::Text("Alpha".into())]);
press(&mut u, Key::Enter, Modifiers::NONE);
assert_eq!(name(&u, a), "Alpha");
assert_eq!(renaming(&u), None);
// From a row with the keyboard (the canvas shortcuts are off there): the row gets it back.
to_layers(&mut u);
assert_eq!(focused_name(&u.ctx), "Alpha");
press(&mut u, Key::F2, Modifiers::NONE);
assert_eq!(renaming(&u), Some(a));
u.frame(vec![]);
u.frame(vec![Event::Text("One".into())]);
press(&mut u, Key::Enter, Modifiers::NONE);
settle(&mut u);
assert_eq!(name(&u, a), "One");
assert_eq!((u.app.ui.nav.region, focused_name(&u.ctx)), (Region::Layers, "One".to_string()), "the row has the keyboard again");
// Escape cancels a rename and stays on the row (it doesn't jump to the canvas).
press(&mut u, Key::F2, Modifiers::NONE);
u.frame(vec![]);
u.frame(vec![Event::Text("Dropped".into())]);
press(&mut u, Key::Escape, Modifiers::NONE);
settle(&mut u);
assert_eq!(name(&u, a), "One");
assert_eq!((u.app.ui.nav.region, focused_name(&u.ctx)), (Region::Layers, "One".to_string()));
}
#[test]
fn shift_f10_opens_the_context_menu_at_the_row_or_the_canvas_selection() {
let (mut u, a, _) = setup();
to_layers(&mut u);
press(&mut u, Key::F10, Modifiers::SHIFT);
settle(&mut u);
let (pos, ids) = u.app.ui.context_menu.clone().expect("the menu opens");
assert_eq!(ids, vec![a]);
assert!(pos.x < u.app.canvas.rect.min.x, "at the row in the layers panel: {pos:?}");
let item = focused_name(&u.ctx);
assert!(!item.is_empty() && item != "Rect A", "the menu has the keyboard: {item:?}");
press(&mut u, Key::Escape, Modifiers::NONE);
settle(&mut u);
assert!(u.app.ui.context_menu.is_none());
assert_eq!(focused_name(&u.ctx), "Rect A", "closing gives the keyboard back to the row");
// On the canvas: below the selection, and the menu takes the keyboard.
press(&mut u, Key::Escape, Modifiers::NONE);
assert_eq!(u.app.ui.nav.region, Region::Canvas);
press(&mut u, Key::F10, Modifiers::SHIFT);
settle(&mut u);
let (pos, ids) = u.app.ui.context_menu.clone().expect("the menu opens on the canvas");
assert_eq!(ids, vec![a]);
let bottom = u.app.to_screen(0.0, 30.0).y;
assert!(u.app.canvas.rect.contains(pos) && pos.y > bottom, "below the selection: {pos:?}");
assert_ne!(focused_name(&u.ctx), "Canvas");
press(&mut u, Key::Escape, Modifiers::NONE);
settle(&mut u);
assert!(u.app.ui.context_menu.is_none());
assert_eq!(focused_name(&u.ctx), "Canvas");
// After a click on a row (no keyboard mode), at that row too.
let p = u.row_at(a);
u.button(p, true);
u.button(p, false);
press(&mut u, Key::F10, Modifiers::SHIFT);
settle(&mut u);
let (pos, _) = u.app.ui.context_menu.clone().expect("the menu opens");
assert!(pos.x < u.app.canvas.rect.min.x && (pos.y - p.y).abs() < 40.0, "{pos:?} near {p:?}");
}
#[test]
fn f8_draws_a_selection_box_the_arrows_move_and_size() {
let (mut u, a, b) = setup();
press(&mut u, Key::F8, Modifiers::NONE);
let [x0, y0, x1, y1] = u.app.ui.key_box.expect("a box around the selection");
assert!(x0 < 0.0 && y0 < 0.0 && x1 > 40.0 && y1 > 30.0 && x1 < 100.0, "{x0} {y0} {x1} {y1}");
// Screen readers hear the box and the keys.
u.ctx.enable_accesskit();
u.frame(vec![]);
let out = u.frame_output(vec![]);
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
let canvas = nodes.iter().find(|(_, n)| n.label() == Some("Canvas")).map(|(_, n)| n.description().unwrap_or_default().to_string());
assert!(canvas.as_deref().is_some_and(|d| d.starts_with("Selection box")), "{canvas:?}");
// Shift+arrows size it from its far corner until it reaches B.
for _ in 0..200 {
if u.app.ui.key_box.is_some_and(|b| b[2] > 125.0) {
break;
}
press(&mut u, Key::ArrowRight, Modifiers::SHIFT);
}
for _ in 0..200 {
if u.app.ui.key_box.is_some_and(|b| b[3] > 55.0) {
break;
}
press(&mut u, Key::ArrowDown, Modifiers::SHIFT);
}
assert_eq!(u.app.ui.key_box.map(|b| [b[0], b[1]]), Some([x0, y0]), "the start stays");
assert_eq!(u.app.session.selection(), vec![a], "nothing selected before Enter");
press(&mut u, Key::Enter, Modifiers::NONE);
let mut sel = u.app.session.selection();
sel.sort();
assert_eq!(sel, {
let mut v = vec![a, b];
v.sort();
v
});
assert!(u.app.ui.key_box.is_none());
// Plain arrows move the whole box; Escape drops it and keeps the selection.
press(&mut u, Key::F8, Modifiers::NONE);
let before = u.app.ui.key_box.unwrap();
press(&mut u, Key::ArrowRight, Modifiers::NONE);
let after = u.app.ui.key_box.unwrap();
assert!(after[0] > before[0] && (after[0] - before[0] - (after[2] - before[2])).abs() < 1e-9);
assert_eq!(u.app.doc_x(a), 0.0, "the arrows don't nudge layers while the box shows");
press(&mut u, Key::Escape, Modifiers::NONE);
assert!(u.app.ui.key_box.is_none());
assert_eq!(u.app.session.selection().len(), 2);
// With nothing selected it starts in the middle of the view.
u.app.run("select.none", json!({}));
press(&mut u, Key::F8, Modifiers::NONE);
let [x0, y0, x1, y1] = u.app.ui.key_box.unwrap();
let c = u.app.to_page(u.app.canvas.rect.center());
assert!(((x0 + x1) / 2.0 - c.x).abs() < 1e-6 && ((y0 + y1) / 2.0 - c.y).abs() < 1e-6);
// A press on the canvas drops it.
let p = u.app.canvas.rect.center();
u.button(p, true);
u.button(p, false);
assert!(u.app.ui.key_box.is_none());
}
impl crate::FigApp {
fn doc_x(&self, id: NodeId) -> f64 {
self.session.doc().unwrap().node(id).unwrap().transform.translation().x
}
}

View File

@ -77,6 +77,8 @@ mod idle_tests;
#[cfg(test)]
mod layers_drag_tests;
#[cfg(test)]
mod layers_keyboard_tests;
#[cfg(test)]
mod layers_keys_tests;
#[cfg(test)]
mod layers_rename_tests;
@ -244,6 +246,12 @@ pub struct UiState {
pub preview: Option<present::Pane>,
pub picker: Option<colorpicker::PickerTarget>,
pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,
/// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the
/// focused layer row or the canvas selection and takes the keyboard.
pub key_menu: bool,
/// A selection box drawn from the keyboard (`select.area`), page `[x0, y0, x1, y1]` with the
/// marquee's start at `x0, y0`.
pub key_box: Option<[f64; 4]>,
/// Layers panel search: open, the filter text, the shift-click range anchor and the selection
/// the tree last revealed (ancestors expand and the row scrolls into view when it changes).
pub layer_search: bool,
@ -339,6 +347,8 @@ impl Default for UiState {
preview: None,
picker: None,
context_menu: None,
key_menu: false,
key_box: None,
layer_search: false,
layer_filter: String::new(),
layer_anchor: None,
@ -563,6 +573,8 @@ impl FigApp {
"preview.close" => self.ui.preview = None,
"open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"),
"layers.collapseAll" => self.ui.expanded.clear(),
"contextMenu" => self.ui.key_menu = true,
"selectArea" => self.start_key_box(),
"layers.rename" => {
let ids: Vec<NodeId> =
r.data.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect();

View File

@ -430,8 +430,15 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
app.ui.main_menu = false;
}
}
// Context menu.
// Context menu (from the keyboard: at the focused layer row or the canvas selection, taking the
// keyboard from the control with it).
if std::mem::take(&mut app.ui.key_menu) {
a11y::context_menu(app, ctx);
}
if let Some((pos, ids)) = app.ui.context_menu.clone() {
if let Some(f) = ctx.memory(|m| m.focused()) {
a11y::popup(&mut app.ui.nav, f, Id::new("ctx_menu"));
}
let mut close = false;
let area = egui::Area::new(Id::new("ctx_menu")).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| {
menu_frame(ui, &t, |ui| {

View File

@ -7,7 +7,7 @@ use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Instant;
use egui::{Event, Pos2, RawInput, Rect, vec2};
use egui::{Event, Key, Modifiers, Pos2, RawInput, Rect, vec2};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::Session;
use serde_json::json;
@ -104,12 +104,74 @@ fn report(layers: usize) {
b.app.ui.layer_filter = "item".into();
b.frame(vec![]);
rows.push(("idle frame, layers filtered by name", b.mean(30, |_| vec![])));
b.app.ui.layer_search = false;
b.app.ui.layer_filter.clear();
// Long lists from the keyboard: every layer expanded, the arrows walking the rows, a layer at
// the bottom of the list revealed, everything collapsed.
let open: Vec<NodeId> = b.app.session.doc().unwrap().nodes().filter(|n| !n.children.is_empty()).map(|n| n.id).collect();
b.app.ui.expanded.extend(open);
rows.push(("first frame, every layer expanded", b.frame(vec![])));
b.app.ui.layers_focus = true;
let top = b.app.session.doc().ok().and_then(|d| d.node(page)?.children.last().copied());
b.app.session.set_selection(top.into_iter().collect());
b.frame(vec![]);
rows.push(("arrow key down the expanded layers", b.mean(30, |_| key(Key::ArrowDown, Modifiers::NONE))));
b.app.ui.expanded.clear();
b.frame(vec![]);
b.app.session.set_selection(bottom_layer(&b.app.session).into_iter().collect());
rows.push(("reveal a layer at the bottom of the list", b.frame(vec![])));
b.app.run("layers.collapseAll", json!({}));
rows.push(("collapse all layers", b.frame(vec![])));
println!("UI frame ms — {layers} layers ({})", if cfg!(debug_assertions) { "debug" } else { "release" });
for (name, v) in rows {
println!("| {name:<44} | {v:>8.2} |");
}
}
/// A key press and release.
fn key(key: Key, modifiers: Modifiers) -> Vec<Event> {
vec![
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
]
}
/// The first screen's header title: the layers panel lists the top of the stack first, so it is
/// near the bottom of the list.
fn bottom_layer(s: &Session) -> Option<NodeId> {
let doc = s.doc().ok()?;
let screen = *doc.node(s.page().ok()?)?.children.get(1)?;
let header = *doc.node(screen)?.children.first()?;
doc.node(header)?.children.first().copied()
}
#[test]
fn a_layer_revealed_at_the_bottom_of_10k_layers_gets_the_keyboard_on_its_row() {
let mut b = Bench::new(10_000);
let title = bottom_layer(&b.app.session).unwrap();
b.app.ui.expanded.clear();
b.app.session.set_selection(vec![title]);
// The list scrolls there (animated over a few frames).
for _ in 0..40 {
b.frame(vec![]);
}
let doc = b.app.session.doc().unwrap();
assert!(doc.ancestors(title).iter().filter(|a| doc.node(**a).is_some_and(|n| !n.is_page())).all(|a| b.app.ui.expanded.contains(a)));
// F6 to the layers panel lands on the row, which is only drawn when it was scrolled into view.
b.frame(key(Key::F6, Modifiers::SHIFT));
b.frame(vec![]);
assert_eq!(crate::a11y::focused_name(&b.ctx), "Title");
// The arrows walk on from it and the keyboard follows.
b.frame(key(Key::ArrowUp, Modifiers::NONE));
for _ in 0..3 {
b.frame(vec![]);
}
let sel = b.app.session.selection();
assert!(sel.len() == 1 && sel != vec![title], "the row above is selected");
let above = b.app.session.doc().unwrap().node(sel[0]).unwrap().name.clone();
assert_eq!(crate::a11y::focused_name(&b.ctx), above);
}
/// The synthetic design system with `icons` icon components (plus its component sets) and
/// `instances` instances using it.
fn design_system(icons: usize, instances: usize) -> (Document, figcraft_engine::synthetic_ds::DesignSystem, Vec<NodeId>) {

View File

@ -1100,23 +1100,20 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:
ui.data_mut(|d| d.insert_temp(adv_id, !open));
}
});
// Per-side weights.
// Per-side weights, two to a row (like the corner radii) so a bound variable's name fits:
// each side binds to a number variable on its own.
if let Some(sides) = common(doc, sel, |n| n.stroke.sides).flatten() {
let quarter = (ui.available_width() - PAD * 2.0 - 3.0 * 6.0) / 4.0;
row(ui, 32.0, |ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for (k, label) in ["T", "R", "B", "L"].iter().enumerate() {
let v = sides.get(k).copied();
let e = widgets::number_field(ui, Id::new(("stroke_side", k)), Lead::Text(label), v, "", quarter, 1.0);
app.field(&ids, "strokeSides", e, |x| {
let mut n = sides;
if let Some(slot) = n.get_mut(k) {
*slot = x.max(0.0);
}
json!(n)
});
}
});
let col = widgets::col_width(ui.available_width());
let fields = ["T", "R", "B", "L"].into_iter().zip(figcraft_engine::cmd::props::STROKE_SIDES);
for first in [0, 2] {
row(ui, 32.0, |ui| {
for (k, (label, field)) in fields.clone().enumerate().skip(first).take(2) {
let v = sides.get(k).copied();
let e = crate::variable_ui::number_field(app, ui, &ids, field, Id::new(("stroke_side", k)), Lead::Text(label), v, "", col);
app.field(&ids, field, e, |x| json!(x.max(0.0)));
}
});
}
}
if ui.data(|d| d.get_temp::<bool>(Id::new("stroke_adv"))).unwrap_or(false) {
stroke_settings(app, ui, doc, sel);

View File

@ -121,6 +121,9 @@ pub fn run_binding(app: &mut FigApp, id: &str) {
}
fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, now: f64, ctx: &Context) -> bool {
if key_box(app, key, m) {
return true;
}
// A gradient open in the colour picker: Delete removes its selected stop.
if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && app.ui.picker.is_some() && crate::handles::remove_selected_stop(app, ctx) {
return true;
@ -179,6 +182,62 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, now: f64, c
false
}
/// Keys while a selection box drawn from the keyboard shows (`select.area`, F8): the arrows move
/// it (with Shift they move its far corner, sizing it) by 10 screen pixels, Enter selects what it
/// covers like a marquee (Shift toggles, Cmd/Ctrl takes nested layers), Escape drops it.
fn key_box(app: &mut FigApp, key: Key, m: Modifiers) -> bool {
let Some(mut b) = app.ui.key_box else { return false };
let step = 10.0 / app.session.ds().map_or(1.0, |d| d.view.zoom.max(1e-6));
let (dx, dy) = match key {
Key::ArrowLeft => (-step, 0.0),
Key::ArrowRight => (step, 0.0),
Key::ArrowUp => (0.0, -step),
Key::ArrowDown => (0.0, step),
_ => (0.0, 0.0),
};
if (dx, dy) != (0.0, 0.0) {
if !m.shift {
b[0] += dx;
b[1] += dy;
}
b[2] += dx;
b[3] += dy;
app.ui.key_box = Some(b);
return true;
}
match key {
Key::Enter => {
app.ui.key_box = None;
let [x0, y0, x1, y1] = b;
app.run("select.area", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "add": m.shift, "deep": m.command}));
true
}
Key::Escape => {
app.ui.key_box = None;
true
}
_ => false,
}
}
impl FigApp {
/// Show a selection box for the keyboard: around the selection, else in the middle of the view.
pub(crate) fn start_key_box(&mut self) {
let Ok(ds) = self.session.ds() else { return };
let zoom = ds.view.zoom.max(1e-6);
let around = ds.selection.iter().filter_map(|i| ds.doc.world_bounds(*i)).reduce(|a, b| a.union(b));
let r = match around {
Some(r) => r.inflate(8.0 / zoom, 8.0 / zoom),
None => {
let c = self.to_page(self.canvas.rect.center());
figcraft_geom::Rect::new(c.x - 100.0 / zoom, c.y - 75.0 / zoom, c.x + 100.0 / zoom, c.y + 75.0 / zoom)
}
};
self.ui.key_box = Some([r.x0, r.y0, r.x1, r.y1]);
self.toast(crate::i18n::tr("Selection box: arrows move it, Shift+arrows size it, Enter selects, Escape cancels"));
}
}
/// The digit a number key types.
fn digit(key: Key) -> Option<u32> {
let name = key.name();

View File

@ -197,6 +197,19 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
the keyboard between the app's regions (toolbar, layers, canvas, properties). In the app, Tab and
Shift+Tab then move between the region's controls, Enter or Space activate one, the arrows move
along the toolbar and through the layer rows, and Escape returns to the canvas (no-ops headless).
- `object.rename {ids?}` without a `name` (`Cmd+R`, or `F2`): rename the layer inline in the app's
layers panel, also from a layer row that has the keyboard; when the rename ends the row gets the
keyboard back (Escape cancels it there). Headless it changes nothing and leaves no undo step.
- `view.contextMenu` (`Shift+F10`): the selection's context menu, at its layer row when the layers
panel has the keyboard (after F6 or a click on a row), else on the canvas below the selection; the
menu takes the keyboard and gives it back when it closes (no-op headless).
- `select.area {x, y, width, height, add?, deep?}`: select what a marquee from `x, y` over that box
(page units) would: top-level layers, a frame only when the box covers it, the children of the
top-level frame the box starts in; `add` toggles like a Shift marquee, `deep` takes nested layers
like Cmd/Ctrl. Returns `{selection}`. Without a box (`F8` in the app) it draws one for the
keyboard around the selection (or in the middle of the view): the arrows move it, Shift+arrows
move its far corner (10 screen pixels a press), Enter selects (Shift+Enter adds, Cmd/Ctrl+Enter
nested layers), Escape or a click drops it.
- `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's
accessibility setting turns it on too). Prototype transitions still play.
- `pref.language {language: system|en|es|…}`: the app's interface language, applied live ("system":
@ -557,8 +570,8 @@ Variables come from the local variables response (`GET /v1/files/:key/variables/
collections with their modes (the default mode first), values per mode, aliases and scopes. Pass it
as `variables` (a path, or the JSON object or text) to `open_file`, `file.open` or `file.import`,
put it next to the file as `<name>.variables.json` or `variables.json`, or embed its `meta` in the
file JSON. Layers' `boundVariables` (sizes, gaps and padding, corner radii, opacity, stroke weight,
visibility, text, font properties, single solid fills and strokes) and `explicitVariableModes` then
file JSON. Layers' `boundVariables` (sizes, gaps and padding, corner radii, opacity, stroke weight and
per-side stroke weights, visibility, text, font properties, single solid fills and strokes) and `explicitVariableModes` then
link to them, so switching a mode with `variable.setMode` restyles the layers (`mode: null` sets a
frame back to Auto, inheriting its parent's mode).
@ -573,6 +586,13 @@ skipped with a warning; library copies and extended collections are never target
added; aliases are checked for loops; it returns `{collections, variables, updated, warnings}`. The
Variables editor's Import… and Export… buttons do the same through file dialogs.
Strokes take colour styles and variables like fills: `style.apply {style, target: "stroke"}` links
a colour style to the strokes (`styles.stroke`; edits of the style follow, `style.detach {which:
"stroke"}` keeps the colour), and `variable.bind` binds `strokeWeight` and each side's weight on its
own (`strokeTopWeight`, `strokeRightWeight`, `strokeBottomWeight`, `strokeLeftWeight`; binding or
setting one side with `node.set` turns on individual strokes, the other sides at the weight) to
number variables scoped for stroke weights.
`variable.bind` also binds gradient stops (`fills/<i>/stops/<j>/color`, `strokes/<i>/stops/<j>/color`;
the binding follows its stop when an earlier stop is removed and its paint when the list is
reordered) and variant properties of instances (`componentProperties/<property>` with a string

View File

@ -47,7 +47,7 @@ blockers.
|---|---|---|---|
| M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — |
| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — |
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | keyboard box selection, interaction pass (Open recent and the outline mode variants landed 2026-10-11) | 6–12 + pass |
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11) | 6–12 + pass |
| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 |
| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 |
| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11) | spell check, Figma Draw multi-edit | 7–18 |

View File

@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@ -19,9 +19,9 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
| Feature breadth (does each Figma Design feature exist, weighted) | **~80%** (core editor areas 1–16: ~91%) | estimated from the area table below |
| Ready for real work | **~49%** | estimated (dimension table below: written weights × written per-dimension scores) |
| Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) |
| Mainstream practitioner | **~46%** | estimated ([below](#mainstream-practitioner): weekly areas 67.8% × discounts 0.9 × 0.9 × 0.85) |
| Mainstream practitioner | **~46%** | estimated ([below](#mainstream-practitioner): weekly areas 68.1% × discounts 0.9 × 0.9 × 0.85) |
| Remaining to beta | **~173–310 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated |
| Remaining to full parity | **~321–618 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
| Remaining to full parity | **~320–615 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were
@ -96,10 +96,10 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|---|---|---|---|---|---|---|
| 1 | Canvas & navigation | 6 | 97% | 72% | Display P3 on screen, QA at extreme zooms (outlines for the selection and mask outlines landed) | 2–5 |
| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 |
| 3 | Layers panel | 5 | 95% | 78% | Keyboard box selection, keyboard rename/context menu, long-list QA | 2–4 |
| 3 | Layers panel | 5 | 98% | 82% | QA with long real files (keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer reveal/expand/collapse/arrow timings landed) | 1–2 |
| 4 | Shapes & vector | 8 | 97% | 68% | Vector QA with real files (live boolean editing, boolean hit testing on the result, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–2 |
| 5 | Paint | 7 | 87% | 66% | Video fills, wide-gamut canvas, embedded image profiles, HEIC images (animated GIF fills and TIFF images landed) | 4–7 |
| 6 | Strokes | 3 | 90% | 72% | Stroke styles (brush strokes landed, see 20) | 1–3 |
| 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 |
| 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 |
| 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 |
| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files; wrapped-line content alignment (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around and cell alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 |
@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 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 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 |
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **163–332** |
| | Core editor only (1–16, ÷94) | 94 | ~91% | ~68% | | 58–113 |
| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **162–329** |
| | Core editor only (1–16, ÷94) | 94 | ~91% | ~68% | | 57–110 |
## By dimension (ready for real work)
| Dimension | Weight | Ready | Hours | Evidence | Doc |
|---|---|---|---|---|---|
| Features (depth) | 35 | 57% | 60–115 (areas 1–13, 15, 16, 20) | The table above; core editor ~68% | this file |
| Features (depth) | 35 | 57% | 59–112 (areas 1–13, 15, 16, 20) | The table above; core editor ~68% | this file |
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
| File formats | 12 | 55% | 8–15 | `.fig` read, best effort (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
| Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
| Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
| **Ready for real work** | 100 | **~49%** | **~321–618** | | |
| **Ready for real work** | 100 | **~49%** | **~320–615** | | |
Dimension hours add up to the full-parity total without double counting: the feature-area rows
for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are
@ -187,7 +187,7 @@ libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a produc
localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration
and is left out as an area, but its effect on working with Figma users is counted in discount 3.
**2. Average depth** ("Ready" column, weighted): **67.8%**.
**2. Average depth** ("Ready" column, weighted): **68.1%**.
**3. Discounts for what still stops real work:**
@ -197,7 +197,7 @@ and is left out as an area, but its effect on working with Figma users is counte
| Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way |
| File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) but writes nothing Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) |
67.8% × 0.90 × 0.90 × 0.85 = **~46%** (estimated).
68.1% × 0.90 × 0.90 × 0.85 = **~46%** (estimated).
**4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments
from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is
@ -523,8 +523,9 @@ Hours in this narrative are the builders' estimates; the tables above supersede
coming only until it arrives (`ui.inspect` `framesDrawn` and `repaintCauses` show what asked for
frames; headless tests hold it). *Left:* a real-world file corpus, Windows/Linux/browser
QA, runs with real screen readers (NVDA, Narrator, VoiceOver, Orca), the AccessKit tree in the
browser (eframe's web backend doesn't send it yet), keyboard box selection, keyboard rename and
context menus in the layers panel. 28–46 h.
browser (eframe's web backend doesn't send it yet). Landed since: F2 rename from the keyboard,
Shift+F10 context menus at the focused layer row or the canvas selection, keyboard box selection
(F8). 27–44 h.
## Inventory: what exists today
@ -590,6 +591,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h |
| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h |
| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h |
| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Import/export breadth 65% → 80%, ready 35% → 50%, 30–60 h → 8–15 h; file formats 40% → 55%, 30–60 h → 8–15 h; feature breadth ~79% → ~80% (core ~90% → ~91%), areas ready ~57% → ~58% (core ~67% → ~68%); ready for real work ~47% → ~49%; essentials ~53% → ~57% (opening files people send ×0.80 → ×0.88); mainstream ~43% → ~46% (weekly areas 66.8% → 67.8%, file exchange ×0.80 → ×0.85); beta ~195–355 h → ~173–310 h; full parity ~345–666 h → ~323–621 h |

View File

@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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-11 · **Change:** minor (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:
@ -20,7 +20,7 @@ interaction feel not yet compared.
| Minimize UI (Cmd+\\) | done | |
| Resizable panels | partial | check widths and snap points against Figma |
| Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity |
| Context menus | done | canvas and layers; keyboard access to the layers context menu missing |
| Context menus | done | canvas and layers; Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) |
| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content |
| Keyboard shortcuts panel | done | docked, tabbed by group, searchable, per-platform keycaps |
| Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored |
@ -47,7 +47,7 @@ interaction feel not yet compared.
| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | partial | check scrub speed and Shift/Alt steps |
| 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 |
| Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection |
| Keyboard box selection | missing | |
| Keyboard box selection | done | F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Shift+arrows size it, Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested); our addition for keyboard-only users |
## Tools
@ -90,6 +90,7 @@ interaction feel not yet compared.
|---|---|---|
| Screen-reader names, roles, values, states (AccessKit) | done on desktop | browser: eframe doesn't send the tree yet |
| Keyboard navigation: F6 regions, Tab within, focus ring | done | |
| Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` |
| Reduced motion | done | system setting or preference |
| WCAG AA contrast of secondary and accent text | done | |
| Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA |
@ -98,6 +99,7 @@ interaction feel not yet compared.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Layers panel keyboard: F2 renames (also from a row with the keyboard, which gets it back), Shift+F10 opens the context menu at the focused row or below the canvas selection, F8 draws a selection box the arrows move and size; stroke weights bind to variables side by side |
| 2026-10-11 | minor | Selection and transform behaviour suite and fixes: Alt shrinks, groups and mixed rotations scale each layer along its own axes, several layers flip together, whole-pixel resizes, small layers move from inside, locked layers stay put, marquees from a top-level frame's background, equal spacing snapping, Cmd/Ctrl ignores constraints while resizing a frame |
| 2026-10-11 | minor | Gamut warnings: the colour picker flags Display P3 colours outside sRGB; View ▸ Soft-proof sRGB stripes them on the canvas |
| 2026-10-11 | minor | Several windows: File ▸ New window, Open in new window on a tab, a tab dragged off the strip; each window has its own tabs and panels and asks about unsaved changes when it closes |