diff --git a/ROADMAP.md b/ROADMAP.md index 5e6a1a3..e4a8674 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-12 · **Change:** minor (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-12 · **Change:** minor (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 @@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md). | | Value | Kind | |---|---|---| -| Menu/command breadth | **100%** (207 / 207) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | +| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | | Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | | Ready for real work | **~53%** | 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)) | @@ -131,6 +131,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-12** · Everyday editing partials: number fields (scrub rates with Shift and Alt, arrow keys by the nudge + preferences, arithmetic with brackets, relative edits of Mixed values per layer), outline mode's hidden layers and + object bounds, Hide when publishing in the Assets panel, a cursor for every tool and modifier, panel widths that are + kept, and dark pages (white text on them) for files made in the dark theme - **2026-10-12** · Sharing with a join code: Share ▸ **Online** (through the server in Preferences, built in) or **Local network**, one Start sharing button and a short code to read out (`K7M-2QX-9TD`; a longer one locally that carries the host's address); others type it under Join with a code (the popover, the home screen), where a link @@ -276,6 +280,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains | | 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged | | 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged | | 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged | diff --git a/crates/ui-egui/src/field_math.rs b/crates/ui-egui/src/field_math.rs new file mode 100644 index 0000000..c831830 --- /dev/null +++ b/crates/ui-egui/src/field_math.rs @@ -0,0 +1,163 @@ +//! What is typed into a number field: a number, arithmetic (`+ - * /` and brackets, with units +//! like `px`, `°` or `%` after numbers), or an edit relative to the value there now (`+10`, `*2`, +//! `/2`, and `50%` where the field isn't a percentage itself). + +/// An edit relative to the value a field has: what `+10`, `*2`, `/4` or `50%` mean. On a field +/// showing mixed values it applies to each layer's own value. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Rel { + Add(f64), + Mul(f64), + Div(f64), +} + +impl Rel { + /// The value `cur` becomes (none when it isn't a number any more: a division by zero). + pub fn apply(self, cur: f64) -> Option { + let v = match self { + Rel::Add(b) => cur + b, + Rel::Mul(b) => cur * b, + Rel::Div(b) if b != 0.0 => cur / b, + Rel::Div(_) => return None, + }; + v.is_finite().then_some(v) + } +} + +/// What a field's text means. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Typed { + Value(f64), + Rel(Rel), +} + +/// Text longer than this isn't a number someone typed. +const MAX_LEN: usize = 256; +/// Brackets nest this deep at most. +const MAX_DEPTH: usize = 32; + +struct Parser<'a> { + chars: std::iter::Peekable>, + /// `%` after a number is its unit (a percentage field); otherwise it isn't part of arithmetic. + percent_unit: bool, +} + +impl Parser<'_> { + fn space(&mut self) { + while self.chars.next_if(|c| c.is_whitespace()).is_some() {} + } + + fn eat(&mut self, c: char) -> bool { + self.space(); + self.chars.next_if_eq(&c).is_some() + } + + /// Sums and differences of products. + fn sum(&mut self, depth: usize) -> Option { + let mut v = self.product(depth)?; + loop { + if self.eat('+') { + v += self.product(depth)?; + } else if self.eat('-') { + v -= self.product(depth)?; + } else { + return v.is_finite().then_some(v); + } + } + } + + fn product(&mut self, depth: usize) -> Option { + let mut v = self.factor(depth)?; + loop { + if self.eat('*') { + v *= self.factor(depth)?; + } else if self.eat('/') { + let d = self.factor(depth)?; + if d == 0.0 { + return None; + } + v /= d; + } else { + return Some(v); + } + } + } + + /// A number with its unit, a bracketed sum, or either negated. + fn factor(&mut self, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + if self.eat('-') { + return self.factor(depth + 1).map(|v| -v); + } + if self.eat('(') { + let v = self.sum(depth + 1)?; + return self.eat(')').then_some(v); + } + let mut digits = String::new(); + while let Some(c) = self.chars.next_if(|c| c.is_ascii_digit() || matches!(c, '.' | ',')) { + // A decimal comma is a decimal point. + digits.push(if c == ',' { '.' } else { c }); + } + let v = digits.parse::().ok().filter(|v| v.is_finite())?; + // The unit: letters (px, pt, ms, x), a degree sign, and a percent sign where that is one. + while self.chars.next_if(|c| c.is_ascii_alphabetic() || *c == '°' || (*c == '%' && self.percent_unit)).is_some() {} + Some(v) + } + + fn end(&mut self) -> bool { + self.space(); + self.chars.peek().is_none() + } +} + +/// What `text` typed into a number field means. `percent_unit`: the field shows a percentage, so a +/// `%` after a number is just its unit; elsewhere a `%` at the end makes the edit relative +/// (`50%`: half the value, `+10%`: a tenth more, `-10%`: a tenth less). +pub fn parse(text: &str, percent_unit: bool) -> Option { + let text = text.trim(); + if text.is_empty() || text.len() > MAX_LEN { + return None; + } + let percent = (!percent_unit).then(|| text.strip_suffix('%')).flatten(); + let body = percent.unwrap_or(text).trim(); + // What it starts with decides whether it is relative ("-4" stays the number minus four). + let (lead, rest) = match body.chars().next() { + Some(c @ ('+' | '*' | '/')) => (Some(c), body.get(c.len_utf8()..).unwrap_or("")), + _ => (None, body), + }; + let mut p = Parser { chars: rest.chars().peekable(), percent_unit }; + let v = p.sum(0).filter(|_| p.end())?; + let rel = match (percent.is_some(), lead) { + (false, None) => return Some(Typed::Value(v)), + (false, Some('+')) => Rel::Add(v), + (false, Some('*')) => Rel::Mul(v), + (false, Some(_)) => Rel::Div(v), + // "50%" of the value, "+10%" more (a typed minus is in `v`: "-10%" is a tenth less). + (true, None) if body.starts_with('-') => Rel::Mul(1.0 + v / 100.0), + (true, None) => Rel::Mul(v / 100.0), + (true, Some('+')) => Rel::Mul(1.0 + v / 100.0), + (true, Some('*')) => Rel::Mul(v / 100.0), + (true, Some(_)) => Rel::Div(v / 100.0), + }; + Some(Typed::Rel(rel)) +} + +/// The number `text` gives a field whose value is `cur` (none: no single value), with every unit +/// read as a unit (`"50%"` is 50). Without a value, `+4` is 4 and other relative edits are nothing. +pub fn eval(text: &str, cur: Option) -> Option { + match (parse(text, true)?, cur) { + (Typed::Value(v), _) => Some(v), + (Typed::Rel(r), Some(c)) => r.apply(c), + (Typed::Rel(Rel::Add(v)), None) => Some(v), + (Typed::Rel(_), None) => None, + } +} + +/// `v` moved `steps` steps of `step` from `from`, without the dust of repeated additions +/// (0.1 + 0.2). +pub fn stepped(from: f64, steps: f64, step: f64) -> f64 { + let v = from + steps * step; + if v.is_finite() { (v * 1e6).round() / 1e6 } else { from } +} diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs new file mode 100644 index 0000000..4635d86 --- /dev/null +++ b/crates/ui-egui/src/field_tests.rs @@ -0,0 +1,406 @@ +//! Number fields: what typed text means (arithmetic, units, edits relative to the value), how far +//! a scrub and an arrow key move a value, and the Design panel's fields driven through egui +//! headless: scrubbing by the label with and without Shift and Alt, arrow keys, arithmetic on +//! commit, Tab and Shift+Tab, Escape, fields showing "Mixed", and one undo step per scrub or +//! commit. + +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::a11y::focused_name; +use crate::field_math::{Rel, Typed, eval, parse, stepped}; +use crate::layers_drag_tests::Ui; +use crate::widgets::{scrub_amount, scrubbed}; + +#[test] +fn typed_text_is_a_number_arithmetic_or_an_edit_relative_to_the_value() { + use Typed::{Rel as R, Value as V}; + // (text, in a percentage field, meaning) + let rows: Vec<(&str, bool, Option)> = vec![ + ("12", false, Some(V(12.0))), + (" 12.5 ", false, Some(V(12.5))), + ("12,5", false, Some(V(12.5))), + (".5", false, Some(V(0.5))), + ("-3", false, Some(V(-3.0))), + ("- 3", false, Some(V(-3.0))), + ("10+5", false, Some(V(15.0))), + ("10 - 3", false, Some(V(7.0))), + ("2*3+4", false, Some(V(10.0))), + ("2+3*4", false, Some(V(14.0))), + ("(2+3)*4", false, Some(V(20.0))), + ("100/(2+3)", false, Some(V(20.0))), + ("-(4+1)", false, Some(V(-5.0))), + ("((1+2)*(3+4))/7", false, Some(V(3.0))), + ("2*-3", false, Some(V(-6.0))), + ("10--2", false, Some(V(12.0))), + ("12px", false, Some(V(12.0))), + ("45°", false, Some(V(45.0))), + ("2x", false, Some(V(2.0))), + ("300ms", false, Some(V(300.0))), + ("10px + 2px", false, Some(V(12.0))), + ("+10", false, Some(R(Rel::Add(10.0)))), + ("+ 2*3", false, Some(R(Rel::Add(6.0)))), + ("*2", false, Some(R(Rel::Mul(2.0)))), + ("/4", false, Some(R(Rel::Div(4.0)))), + ("*(1+1)", false, Some(R(Rel::Mul(2.0)))), + ("50%", false, Some(R(Rel::Mul(0.5)))), + ("+10%", false, Some(R(Rel::Mul(1.1)))), + ("-10%", false, Some(R(Rel::Mul(0.9)))), + ("50%", true, Some(V(50.0))), + ("+10%", true, Some(R(Rel::Add(10.0)))), + ("50% + 10%", true, Some(V(60.0))), + ("", false, None), + (" ", false, None), + ("abc", false, None), + ("1/0", false, None), + ("/0", false, Some(R(Rel::Div(0.0)))), + ("(1+2", false, None), + ("1+2)", false, None), + ("1+", false, None), + ("*", false, None), + ("1 2", false, None), + ("1..2", false, None), + ("50%%", false, None), + ("5%+1", false, None), + ("1e999", false, None), + ("٣", false, None), + ]; + for (text, pct, want) in rows { + let got = parse(text, pct); + match (got, want) { + (Some(V(a)), Some(V(b))) + | (Some(R(Rel::Add(a))), Some(R(Rel::Add(b)))) + | (Some(R(Rel::Mul(a))), Some(R(Rel::Mul(b)))) + | (Some(R(Rel::Div(a))), Some(R(Rel::Div(b)))) => { + assert!((a - b).abs() < 1e-9, "{text:?}: {got:?} (wanted {want:?})"); + } + (None, None) => {} + _ => panic!("{text:?} (percentage field: {pct}): {got:?} (wanted {want:?})"), + } + } + // Relative edits: the value they give, and nothing where there is none. + assert_eq!(Rel::Add(10.0).apply(5.0), Some(15.0)); + assert_eq!(Rel::Mul(2.0).apply(5.0), Some(10.0)); + assert_eq!(Rel::Div(4.0).apply(10.0), Some(2.5)); + assert_eq!(Rel::Div(0.0).apply(10.0), None); + assert_eq!(Rel::Mul(f64::MAX).apply(f64::MAX), None); + // `eval`: every unit is a unit; without a value `+4` is 4. + for (text, cur, want) in [ + ("12", None, Some(12.0)), + ("10+5", None, Some(15.0)), + ("*2", Some(8.0), Some(16.0)), + ("+4", Some(8.0), Some(12.0)), + ("+4", None, Some(4.0)), + ("*2", None, None), + ("-3", None, Some(-3.0)), + ("50%", None, Some(50.0)), + ("1/0", None, None), + ("abc", None, None), + ] { + assert_eq!(eval(text, cur), want, "eval({text:?}, {cur:?})"); + } + // Junk never panics: long, deep and odd text. + for junk in ["(".repeat(5000), "1+".repeat(5000), format!("{}1{}", "(".repeat(40), ")".repeat(40)), "−1".into(), "1\u{0}2".into(), "é%".into()] + { + let _ = parse(&junk, false); + let _ = parse(&junk, true); + } +} + +#[test] +fn a_scrub_moves_a_step_a_point_ten_with_shift_and_a_tenth_with_alt() { + // (points dragged, the field's step, Shift, Alt) → how far the value moves + let rows = [ + ((25.0, 1.0, false, false), 25.0), + ((-25.0, 1.0, false, false), -25.0), + ((10.0, 1.0, true, false), 100.0), + ((20.0, 1.0, false, true), 2.0), + ((20.0, 1.0, true, true), 20.0), + ((10.0, 0.1, false, false), 1.0), + ((10.0, 0.1, true, false), 10.0), + ((10.0, 10.0, false, false), 100.0), + ((0.0, 1.0, true, false), 0.0), + ]; + for ((dx, step, shift, alt), want) in rows { + let got = scrub_amount(dx, step, shift, alt); + assert!((got - want).abs() < 1e-9, "{dx} points, step {step}, shift {shift}, alt {alt}: {got} (wanted {want})"); + } + // The value shown: whole steps from where the scrub began (it keeps its fraction), tenths + // with Alt, and no dust from adding tenths. + for ((from, amount, quantum), want) in [ + ((12.5, 3.4, 1.0), 15.5), + ((12.5, -3.6, 1.0), 8.5), + ((10.0, 0.34, 0.1), 10.3), + ((0.1, 0.2, 0.1), 0.3), + ((5.0, 7.0, 0.0), 5.0), + ((5.0, f64::NAN, 1.0), 5.0), + ] { + assert_eq!(scrubbed(from, amount, quantum), want, "from {from}, moved {amount}, quantum {quantum}"); + } + assert_eq!(stepped(0.1, 1.0, 0.2), 0.3); + assert_eq!(stepped(1.0, 1.0, f64::INFINITY), 1.0); +} + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// Rectangles A (10, 20, 40×30) and B (100, 50, 20×20); A is selected. +fn setup() -> (Ui, NodeId, NodeId) { + let mut s = Session::new(); + let a = id(s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 40, "height": 30})).unwrap()); + let b = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 20, "height": 20})).unwrap()); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + u.frame(vec![]); + (u, a, b) +} + +/// The field named `name` in the Design panel: the middle of its value, and what it shows. +fn field(u: &mut Ui, name: &str) -> (Pos2, String) { + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = + nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::SpinButton && n.label() == Some(name)).unwrap_or_else(|| panic!("no field {name:?}")); + let b = n.bounds().expect("bounds"); + (pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32), n.value().unwrap_or_default().to_string()) +} + +/// The middle of field `name`'s label (what scrubs): just left of its value. +fn label(u: &mut Ui, name: &str) -> Pos2 { + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::SpinButton && n.label() == Some(name)).unwrap(); + let b = n.bounds().unwrap(); + pos2(b.x0 as f32 - 12.0, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn geom(u: &Ui, n: NodeId) -> (f64, f64, f64, f64) { + let doc = u.app.session.doc().unwrap(); + let b = doc.world_bounds(n).unwrap(); + (b.x0, b.y0, b.width(), b.height()) +} + +fn undo_steps(u: &Ui) -> usize { + u.app.session.ds().unwrap().history.undo.len() +} + +/// A key pressed and let go within one frame (a quick tap, and what the control channel sends). +fn key(u: &mut Ui, key: Key, modifiers: Modifiers) { + let event = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + u.frame_with(vec![event(true), event(false)], modifiers); + u.frame(vec![]); +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +/// Click into field `name`, type `text` over what it shows and press `end`. +fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) { + let (p, _) = field(u, name); + click(u, p); + u.frame(vec![Event::Text(text.into())]); + key(u, end, Modifiers::NONE); + u.frame(vec![]); +} + +/// Scrub field `name` by its label: `dx` points with `modifiers` held, then let go. +fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) { + let from = label(u, name); + let press = |pressed| Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed, modifiers }; + u.frame_with(vec![Event::PointerMoved(from), press(true)], modifiers); + let steps = 10; + for k in 1..=steps { + u.frame_with(vec![Event::PointerMoved(pos2(from.x + dx * k as f32 / steps as f32, from.y))], modifiers); + } + let to = pos2(from.x + dx, from.y); + u.frame_with( + vec![Event::PointerMoved(to), Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }], + modifiers, + ); + u.frame(vec![]); +} + +#[test] +fn scrubbing_the_label_moves_the_value_and_is_one_undo_step() { + let (mut u, a, _) = setup(); + let shift = Modifiers { shift: true, ..Modifiers::NONE }; + let alt = Modifiers { alt: true, ..Modifiers::NONE }; + // (field, points, modifiers, x after): X starts at 10. + let rows = [ + ("X", 25.0, Modifiers::NONE, 35.0), + ("X", 10.0, shift, 135.0), + ("X", 20.0, alt, 137.0), + ("X", -37.0, Modifiers::NONE, 100.0), + ("X", -8.0, shift, 20.0), + ]; + for (name, dx, m, want) in rows { + let steps = undo_steps(&u); + scrub(&mut u, name, dx, m); + assert_eq!(geom(&u, a).0, want, "{name} scrubbed {dx} points with {m:?}"); + assert_eq!(undo_steps(&u), steps + 1, "one undo step for the whole scrub"); + } + u.app.run("edit.undo", json!({})); + assert_eq!(geom(&u, a).0, 100.0, "undo takes back the last scrub alone"); + // A few points aren't a drag yet (a click on the label), then it counts from the press. + // Alt gives tenths (the field shows them); the fraction stays under a plain scrub. + let width = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().width; + scrub(&mut u, "Width", 7.0, alt); + assert_eq!(width(&u), 40.7); + scrub(&mut u, "Width", 8.0, Modifiers::NONE); + assert_eq!(width(&u), 48.7); + // Fractional fields: opacity moves a percent a point, rotation a degree. + scrub(&mut u, "Opacity", -30.0, Modifiers::NONE); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().opacity, 0.7); + scrub(&mut u, "Rotation", 15.0, Modifiers::NONE); + let rot = figcraft_geom::rotation_degrees(u.app.session.doc().unwrap().node(a).unwrap().transform); + assert!((rot.abs() - 15.0).abs() < 1e-6, "rotation {rot}"); + // A press on the label that doesn't move changes nothing. + let before = (geom(&u, a), undo_steps(&u)); + let p = label(&mut u, "Y"); + click(&mut u, p); + assert_eq!((geom(&u, a), undo_steps(&u)), before); +} + +#[test] +fn the_arrow_keys_step_by_the_nudge_amounts_and_fractional_fields_by_their_own() { + let (mut u, a, _) = setup(); + let shift = Modifiers { shift: true, ..Modifiers::NONE }; + let (p, _) = field(&mut u, "Width"); + click(&mut u, p); + key(&mut u, Key::ArrowUp, Modifiers::NONE); + assert_eq!(geom(&u, a).2, 41.0, "Up: the small nudge"); + key(&mut u, Key::ArrowUp, shift); + assert_eq!(geom(&u, a).2, 51.0, "Shift+Up: the big nudge"); + key(&mut u, Key::ArrowDown, shift); + key(&mut u, Key::ArrowDown, Modifiers::NONE); + assert_eq!(geom(&u, a).2, 40.0); + // The nudge preferences are the steps of a field in pixels. + u.app.run("pref.nudge", json!({"small": 2, "big": 25})); + key(&mut u, Key::ArrowUp, Modifiers::NONE); + key(&mut u, Key::ArrowUp, shift); + assert_eq!(geom(&u, a).2, 67.0); + // From what is typed, arithmetic included. + u.frame(vec![Event::Text("10*2".into())]); + key(&mut u, Key::ArrowUp, Modifiers::NONE); + assert_eq!(geom(&u, a).2, 22.0); + key(&mut u, Key::Enter, Modifiers::NONE); + u.frame(vec![]); + assert_eq!(geom(&u, a).2, 22.0, "Enter after the step commits nothing more"); + // Opacity steps a percent and ten, rotation a degree and ten, whatever the nudge. + let (p, _) = field(&mut u, "Opacity"); + click(&mut u, p); + key(&mut u, Key::ArrowDown, Modifiers::NONE); + key(&mut u, Key::ArrowDown, shift); + key(&mut u, Key::Enter, Modifiers::NONE); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().opacity, 0.89); + let (p, _) = field(&mut u, "Rotation"); + click(&mut u, p); + key(&mut u, Key::ArrowUp, Modifiers::NONE); + key(&mut u, Key::ArrowUp, shift); + key(&mut u, Key::Escape, Modifiers::NONE); + let rot = figcraft_geom::rotation_degrees(u.app.session.doc().unwrap().node(a).unwrap().transform); + assert!((rot.abs() - 11.0).abs() < 1e-6, "rotation {rot}: the steps stay after Escape"); +} + +#[test] +fn arithmetic_commits_tab_moves_on_and_escape_restores() { + let (mut u, a, _) = setup(); + // (field, typed) → (x, y, w, h) + let rows = [ + ("X", "(10+5)*2", (30.0, 20.0, 40.0, 30.0)), + ("X", "*2", (60.0, 20.0, 40.0, 30.0)), + ("X", "+15", (75.0, 20.0, 40.0, 30.0)), + ("X", "/3", (25.0, 20.0, 40.0, 30.0)), + ("Y", "-8", (25.0, -8.0, 40.0, 30.0)), + ("Width", "50%", (25.0, -8.0, 20.0, 30.0)), + ("Height", "30px + 12", (25.0, -8.0, 20.0, 42.0)), + ("Height", "nonsense", (25.0, -8.0, 20.0, 42.0)), + ("Height", "1/0", (25.0, -8.0, 20.0, 42.0)), + ]; + for (name, text, want) in rows { + type_into(&mut u, name, text, Key::Enter); + assert_eq!(geom(&u, a), want, "{text:?} typed into {name}"); + } + // In a percentage field `%` is the unit. + type_into(&mut u, "Opacity", "50%", Key::Enter); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().opacity, 0.5); + // One undo step per commit; a field left as it was commits nothing. + let steps = undo_steps(&u); + type_into(&mut u, "X", "40", Key::Enter); + assert_eq!(undo_steps(&u), steps + 1); + let (p, _) = field(&mut u, "X"); + click(&mut u, p); + key(&mut u, Key::Enter, Modifiers::NONE); + click(&mut u, p); + let (other, _) = field(&mut u, "Y"); + click(&mut u, other); + key(&mut u, Key::Escape, Modifiers::NONE); + assert_eq!(undo_steps(&u), steps + 1, "Enter or a click away from an untouched field isn't an edit"); + // Escape restores: nothing of what was typed is kept. + type_into(&mut u, "X", "999", Key::Escape); + assert_eq!((geom(&u, a).0, undo_steps(&u)), (40.0, steps + 1)); + assert_eq!(field(&mut u, "X").1, "40", "the field shows the value again"); + // Tab commits and moves to the next field, Shift+Tab back; what is typed there replaces it. + type_into(&mut u, "X", "77", Key::Tab); + assert_eq!(geom(&u, a).0, 77.0, "Tab commits"); + assert_eq!(focused_name(&u.ctx), "Y", "and moves on"); + u.frame(vec![Event::Text("5".into())]); + u.frame(vec![Event::Text("5".into())]); + key(&mut u, Key::Tab, Modifiers::SHIFT); + u.frame(vec![]); + assert_eq!(geom(&u, a).1, 55.0, "Shift+Tab commits too"); + assert_eq!(focused_name(&u.ctx), "X", "and moves back"); + // With the keyboard on a field and nothing typed, the arrows step it. + key(&mut u, Key::ArrowUp, Modifiers::NONE); + assert_eq!(geom(&u, a).0, 78.0); +} + +#[test] +fn mixed_values_take_edits_relative_to_each_layer() { + let (mut u, a, b) = setup(); + u.app.run("select.set", json!({"ids": [a, b]})); + assert_eq!(field(&mut u, "X").1, "Mixed"); + assert_eq!(field(&mut u, "Width").1, "Mixed"); + let steps = undo_steps(&u); + // (field, typed) → A's and B's (x, y, w, h) after it + let rows = [ + ("X", "+10", (20.0, 20.0, 40.0, 30.0), (110.0, 50.0, 20.0, 20.0)), + ("Width", "*2", (20.0, 20.0, 80.0, 30.0), (110.0, 50.0, 40.0, 20.0)), + ("Width", "/4", (20.0, 20.0, 20.0, 30.0), (110.0, 50.0, 10.0, 20.0)), + ("Height", "50%", (20.0, 20.0, 20.0, 15.0), (110.0, 50.0, 10.0, 10.0)), + ("Y", "+ (2*3)", (20.0, 26.0, 20.0, 15.0), (110.0, 56.0, 10.0, 10.0)), + ("Y", "/0", (20.0, 26.0, 20.0, 15.0), (110.0, 56.0, 10.0, 10.0)), + ]; + let mut edits = 0; + for (name, text, wa, wb) in rows { + let before = (geom(&u, a), geom(&u, b)); + type_into(&mut u, name, text, Key::Enter); + assert_eq!((geom(&u, a), geom(&u, b)), (wa, wb), "{text:?} typed into {name} showing Mixed"); + edits += usize::from(before != (wa, wb)); + assert_eq!(undo_steps(&u), steps + edits, "{text:?}: one undo step for all the layers"); + } + // One undo takes the last edit back from both layers. + u.app.run("edit.undo", json!({})); + assert_eq!((geom(&u, a).1, geom(&u, b).1), (20.0, 50.0)); + // An arrow key steps each layer from its own value. + let (p, _) = field(&mut u, "X"); + click(&mut u, p); + key(&mut u, Key::ArrowUp, Modifiers::NONE); + key(&mut u, Key::ArrowUp, Modifiers { shift: true, ..Modifiers::NONE }); + key(&mut u, Key::Escape, Modifiers::NONE); + assert_eq!((geom(&u, a).0, geom(&u, b).0), (31.0, 121.0)); + // A plain number is that value for all of them: the field isn't mixed any more. + type_into(&mut u, "X", "64", Key::Enter); + assert_eq!((geom(&u, a).0, geom(&u, b).0), (64.0, 64.0)); + assert_eq!(field(&mut u, "X").1, "64"); +} diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 408d645..ec919a2 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -148,15 +148,16 @@ fn limit(n: &Node, horizontal: bool, is_min: bool) -> Option { fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has_al: bool) { let ids = sel.to_vec(); - let w = common(doc, sel, |n| (n.width * 100.0).round() / 100.0); - let h = common(doc, sel, |n| (n.height * 100.0).round() / 100.0); + let w_of = |n: &Node| (n.width * 100.0).round() / 100.0; + let h_of = |n: &Node| (n.height * 100.0).round() / 100.0; + let (w, h) = (common(doc, sel, w_of), common(doc, sel, h_of)); let col = widgets::col_width(ui.available_width()); widgets::sublabels(ui, tr("Dimensions"), None); row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field(app, ui, &ids, "width", Id::new("lay_w"), Lead::Text("W"), w, "", col); - app.field(&ids, "width", e, |v| json!(v.max(0.0))); - let e = crate::variable_ui::number_field(app, ui, &ids, "height", Id::new("lay_h"), Lead::Text("H"), h, "", col); - app.field(&ids, "height", e, |v| json!(v.max(0.0))); + let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "width", Id::new("lay_w"), Lead::Text("W"), w, "", col); + app.field_of(&ids, "width", e, w_of, |v| json!(v.max(0.0))); + let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "height", Id::new("lay_h"), Lead::Text("H"), h, "", col); + app.field_of(&ids, "height", e, h_of, |v| json!(v.max(0.0))); let locked = common(doc, sel, |n| n.constrain_proportions).unwrap_or(false); if icons::button(ui, if locked { Icon::LinkOn } else { Icon::LinkOff }, 24.0, locked, tr("Constrain proportions")).clicked() { app.set_props(&ids, json!({"constrainProportions": !locked})); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index aff5587..74a04e0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ pub mod dev_compare; pub mod dev_mode; pub mod dialogs; pub mod export_panel; +pub mod field_math; pub mod font_preview; pub mod frame_labels; pub mod frame_presets; @@ -75,6 +76,8 @@ mod cursors_tests; #[cfg(test)] mod dev_mode_tests; #[cfg(test)] +mod field_tests; +#[cfg(test)] mod frame_label_tests; #[cfg(test)] mod idle_tests; @@ -947,6 +950,7 @@ impl FigApp { icons::gallery(&ctx); } theme::set_doc_profile(&ctx, self.session.doc().map_or(figcraft_color::ColorProfile::Srgb, |d| d.color_profile)); + widgets::set_nudge(&ctx, self.session.prefs.nudge_small, self.session.prefs.nudge_big); // The Inspect tab measures on hover without Alt. self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; if self.ui.present.is_some() { diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index a9c6147..a53ee48 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -12,7 +12,7 @@ use crate::icons::{self, Icon}; use crate::list_drag::{ListDrag, move_flags}; use crate::style_picker::{self, StyleSlot}; use crate::theme::{ACCENT, Tokens, regular, semibold}; -use crate::widgets::{self, FIELD_H, FieldEdit, Lead, PAD}; +use crate::widgets::{self, Edit, FIELD_H, FieldEdit, Lead, PAD}; use crate::{FigApp, RightTab}; /// Common value of `f` across `ids` (None when mixed or empty). @@ -39,6 +39,27 @@ impl FigApp { self.field_run(edit, "node.set", |v| json!({"ids": ids, "props": { key: map(v) }})); } + /// [`Self::field`] for a field showing `shown(layer)`: a relative edit of mixed values (`+10`, + /// `*2`, an arrow key's step) goes to each layer from its own value, in one undo step. + pub fn field_of(&mut self, ids: &[NodeId], key: &str, edit: Option, shown: impl Fn(&Node) -> f64, map: impl Fn(f64) -> Value) { + let rel = match edit { + Some(Edit::Each(rel)) => rel, + Some(Edit::All(e)) => return self.field(ids, key, Some(e), map), + None => return, + }; + let Ok(doc) = self.session.doc() else { return }; + let values: Vec<(NodeId, f64)> = ids.iter().filter_map(|i| Some((*i, rel.apply(shown(doc.node(*i)?))?))).collect(); + if values.is_empty() || self.session.begin_interaction("Edit").is_err() { + return; + } + for (id, v) in values { + if let Err(e) = self.session.execute("node.set", &json!({"ids": [id], "props": { key: map(v) }})) { + self.failed("node.set", &e); + } + } + let _ = self.session.commit_interaction(); + } + /// Apply a field edit through any command: `params` builds the command's params from the value. pub fn field_run(&mut self, edit: Option, cmd: &str, params: impl Fn(f64) -> Value) { match edit { @@ -582,17 +603,19 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] } }); // In the frame's space: groups have none of their own. - let x = common(doc, sel, |n| (doc.frame_relative(n.id).as_coeffs()[4] * 100.0).round() / 100.0); - let y = common(doc, sel, |n| (doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0); - let rot = common(doc, sel, |n| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0); + // What each field shows of a layer (a relative edit of mixed values starts from it). + let x_of = |n: &Node| (doc.frame_relative(n.id).as_coeffs()[4] * 100.0).round() / 100.0; + let y_of = |n: &Node| (doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0; + let rot_of = |n: &Node| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0; + let (x, y, rot) = (common(doc, sel, x_of), common(doc, sel, y_of), common(doc, sel, rot_of)); let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); let absolute = common(doc, sel, |n| n.child_layout.absolute); widgets::sublabels(ui, tr("Position"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("pos_x"), Lead::Text("X"), x, "", col, 1.0); - app.field(&ids, "x", e, |v| json!(v)); - let e = widgets::number_field(ui, Id::new("pos_y"), Lead::Text("Y"), y, "", col, 1.0); - app.field(&ids, "y", e, |v| json!(v)); + let e = widgets::number_field_mixed(ui, Id::new("pos_x"), Lead::Text("X"), x, "", col, 1.0); + app.field_of(&ids, "x", e, x_of, |v| json!(v)); + let e = widgets::number_field_mixed(ui, Id::new("pos_y"), Lead::Text("Y"), y, "", col, 1.0); + app.field_of(&ids, "y", e, y_of, |v| json!(v)); // Children of auto layout frames can ignore the layout (absolute position). if parent_al { let on = absolute.unwrap_or(false); @@ -611,8 +634,8 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] }); widgets::sublabels(ui, tr("Rotation"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("pos_rot"), Lead::Icon(Icon::CornerRadius, tr("Rotation")), rot, "°", col, 1.0); - app.field(&ids, "rotation", e, |v| json!(v)); + let e = widgets::number_field_mixed(ui, Id::new("pos_rot"), Lead::Icon(Icon::CornerRadius, tr("Rotation")), rot, "°", col, 1.0); + app.field_of(&ids, "rotation", e, rot_of, |v| json!(v)); let items = [ (Icon::Rotate, tr("Rotate 90° right")), (Icon::FlipH, crate::shortcuts::tip(tr("Flip horizontal"), "object.flipH")), @@ -796,17 +819,19 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { }); }); } - let op = common(doc, sel, |n| (f64::from(n.opacity) * 100.0).round()); + let op_of = |n: &Node| (f64::from(n.opacity) * 100.0).round(); + let op = common(doc, sel, op_of); let radius_ok = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_radius())); // Only rectangles and frames round each corner on its own. let indep_ok = radius_ok && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_independent_corners())); let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0)); widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius"))); row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field(app, ui, &ids, "opacity", Id::new("ap_op"), Lead::Icon(Icon::Opacity, tr("Opacity")), op, "%", col); - app.field(&ids, "opacity", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); + let lead = Lead::Icon(Icon::Opacity, tr("Opacity")); + let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "opacity", Id::new("ap_op"), lead, op, "%", col); + app.field_of(&ids, "opacity", e, op_of, |v| json!((v / 100.0).clamp(0.0, 1.0))); if radius_ok { - let e = crate::variable_ui::number_field( + let e = crate::variable_ui::number_field_mixed( app, ui, &ids, @@ -817,7 +842,9 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { "", col, ); - app.field(&ids, "cornerRadius", e, |v| json!(v.max(0.0))); + // A layer with corners of its own starts from its largest. + let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)); + app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0))); let open = ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false); if indep_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tr("Individual corners")).clicked() { ui.data_mut(|d| d.insert_temp(Id::new("indep_corners"), !open)); @@ -1104,7 +1131,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: }); let sided = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle))); let weight_w = half - if sided { 60.0 } else { 32.0 }; - let e = crate::variable_ui::number_field( + let e = crate::variable_ui::number_field_mixed( app, ui, &ids, @@ -1115,7 +1142,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: "", weight_w, ); - app.field(&ids, "strokeWeight", e, |v| json!(v.max(0.0))); + app.field_of(&ids, "strokeWeight", e, |n| n.stroke.weight, |v| json!(v.max(0.0))); if sided { let sides_btn = icons::button(ui, Icon::IndependentCorners, 24.0, false, tr("Individual strokes")); menu_popup(&sides_btn).show(|ui| { diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index edee865..17e1b22 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, FieldEdit, Lead}; +use crate::widgets::{self, BindEvent, Edit, FieldEdit, Lead}; /// What picking a variable does. #[derive(Clone)] @@ -245,6 +245,37 @@ 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`] over several layers: a relative edit of mixed values comes back as one for +/// each layer ([`Edit::Each`]; see `FigApp::field_of`). +pub fn number_field_mixed( + app: &mut FigApp, + ui: &mut Ui, + ids: &[NodeId], + field: &str, + id: Id, + lead: Lead, + value: Option, + suffix: &str, + width: f32, +) -> Option { + number_field_edit(app, ui, ids, field, id, value, width, tr("Mixed"), lead, suffix) +} + +fn number_field_edit( + app: &mut FigApp, + ui: &mut Ui, + ids: &[NodeId], + field: &str, + id: Id, + value: Option, + width: f32, + empty: &str, + lead: Lead, + suffix: &str, +) -> Option { let name = app.session.doc().ok().and_then(|d| bound_name(d, ids, field)); let (edit, ev) = widgets::number_field_bindable(ui, id, lead, value, suffix, width, 1.0, Some(name.as_deref()), empty); handle(app, ui, ids, field, VariableType::Number, ev); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 33a5ba0..508e6fc 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -4,6 +4,7 @@ use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use crate::a11y::{self, Tip}; +pub use crate::field_math::{Rel, Typed, eval, parse, stepped}; use crate::i18n::tr; use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; @@ -38,12 +39,67 @@ impl Lead<'_> { /// Result of a field edit. pub enum FieldEdit { - /// Committed a new value (Enter / focus loss). + /// Committed a new value (Enter, Tab or focus loss; an arrow key's step). Set(f64), /// Scrubbed by dragging the label (live; `.1` is true when the drag ended). Scrub(f64, bool), } +/// What a field over several layers gives ([`number_field_mixed`]): one value for all of them, or +/// (while it shows no single value: "Mixed") an edit relative to each layer's own (`+10`, `*2`, an +/// arrow key's step). +pub enum Edit { + All(FieldEdit), + Each(Rel), +} + +impl Edit { + /// For a field that has no value per layer to work from: `+10` is 10, as typed into an empty + /// field, and other relative edits are nothing. + pub fn settled(self) -> Option { + match self { + Edit::All(e) => Some(e), + Edit::Each(Rel::Add(v)) => Some(FieldEdit::Set(v)), + Edit::Each(_) => None, + } + } +} + +/// The nudge amounts the arrow keys step pixel fields by (plain, with Shift): the preferences' +/// small and big nudge. Set once a frame. +pub fn set_nudge(ctx: &egui::Context, small: f64, big: f64) { + ctx.data_mut(|d| d.insert_temp(Id::new("field_nudge"), (small, big))); +} + +/// What an arrow key (with Shift: `.1`) and a pixel of scrubbing step a field by: a field in +/// pixels (no unit, whole steps) takes the nudge preferences from the keys, every other field +/// its own step and ten of them (1% and 10% of opacity, 1° and 10° of rotation). +fn key_steps(ctx: &egui::Context, suffix: &str, step: f64) -> (f64, f64) { + let nudge = + ctx.data(|d| d.get_temp::<(f64, f64)>(Id::new("field_nudge"))).filter(|(s, b)| s.is_finite() && b.is_finite() && *s > 0.0 && *b > 0.0); + match nudge { + Some(n) if suffix.is_empty() && step == 1.0 => n, + _ => (step, step * 10.0), + } +} + +/// How far a scrub of `dx` points moves a value whose field steps by `step`: 1 step a point, ten +/// with Shift, a tenth with Alt (fine). +pub fn scrub_amount(dx: f64, step: f64, shift: bool, alt: bool) -> f64 { + dx * step + * match (shift, alt) { + (true, false) => 10.0, + (false, true) => 0.1, + _ => 1.0, + } +} + +/// The value a scrub that began at `from` and has moved `amount` (see [`scrub_amount`]) shows: +/// whole steps of `quantum` away from where it began. +pub fn scrubbed(from: f64, amount: f64, quantum: f64) -> f64 { + if quantum > 0.0 && quantum.is_finite() { stepped(from, (amount / quantum).round(), quantum) } else { from } +} + pub(crate) fn fmt_num(v: f64) -> String { if !v.is_finite() { return String::new(); @@ -63,12 +119,19 @@ pub enum BindEvent { /// A number field `width` wide. `value` None shows "Mixed". `suffix` (e.g. "%" or "°") is shown /// after the number. Typing supports simple math (`10+5`, `*2`). pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: &str, width: f32, step: f64) -> Option { + number_field_mixed(ui, id, lead, value, suffix, width, step)?.settled() +} + +/// [`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`] that shows `empty` (dim) for no value instead of "Mixed" (e.g. "Auto"). +/// [`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 + number_field_impl(ui, id, Lead::None, value, "", width, step, None, empty).0?.settled() } /// [`number_field`] that can bind a variable: `binding` None = not bindable, `Some(None)` = shows @@ -84,7 +147,7 @@ pub fn number_field_bindable( step: f64, binding: Option>, empty: &str, -) -> (Option, Option) { +) -> (Option, Option) { number_field_impl(ui, id, lead, value, suffix, width, step, binding, empty) } @@ -98,12 +161,13 @@ fn number_field_impl( step: f64, binding: Option>, empty: &str, -) -> (Option, Option) { +) -> (Option, Option) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); if let Some(Some(name)) = binding { return (None, bound_field(ui, id, lead, rect, name)); } + let mut out: Option = None; let lead_w = match lead { Lead::None | Lead::Named(_) => 6.0, Lead::Text(s) => (12.0 + s.chars().count() as f32 * 7.0).max(24.0), @@ -137,40 +201,78 @@ fn number_field_impl( if lead_resp.hovered() || lead_resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } - let mut out = None; - // Scrub by dragging the label. + // Scrub by dragging the label: a step a point, ten with Shift, a tenth with Alt. if let Some(v) = value { - let dx_id = id.with("scrub_dx"); - if lead_resp.drag_started() { - ui.data_mut(|d| { - d.insert_temp(id.with("scrub0"), v); - d.insert_temp(dx_id, 0.0_f64); - }); - } + // Where the scrub began and how far it has taken the value (the modifiers can change on + // the way: each stretch counts at its own rate). + let scrub_id = id.with("scrub"); if lead_resp.dragged() || lead_resp.drag_stopped() { - let v0: f64 = ui.data(|d| d.get_temp(id.with("scrub0"))).unwrap_or(v); - // The release frame reports no drag any more: the scrub ends where the last frame left it. - let total = match lead_resp.total_drag_delta() { - Some(d) => { - ui.data_mut(|m| m.insert_temp(dx_id, f64::from(d.x))); - f64::from(d.x) - } - None => ui.data(|m| m.get_temp(dx_id)).unwrap_or(0.0), - }; - let shift = ui.input(|i| i.modifiers.shift); - let k = if shift { step * 10.0 } else { step }; - let nv = v0 + (total * k).round(); - out = Some(FieldEdit::Scrub(nv, lead_resp.drag_stopped())); + let began = lead_resp.drag_started(); + let (v0, mut amount): (f64, f64) = ui.data(|d| d.get_temp(scrub_id)).filter(|_| !began).unwrap_or((v, 0.0)); + let (dx, shift, alt) = ui.input(|i| { + let p = &i.pointer; + // As it begins: all the way from the press (a drag is one only after a few points). + let moved = if began { p.hover_pos().zip(p.press_origin()).map_or(0.0, |(at, from)| at.x - from.x) } else { p.delta().x }; + // With the pointer stopped at the edge of the screen (or of a window that fills + // it) the mouse's own motion goes on, where the platform reports it: the scrub + // does too. Elsewhere the scrub just stops there. + let raw = p.motion().map_or(0.0, |m| m.x / i.pixels_per_point); + let at_edge = moved == 0.0 && p.hover_pos().is_none_or(|at| at.x <= 1.0 || at.x >= i.content_rect().max.x - 1.0); + (f64::from(if at_edge { raw } else { moved }), i.modifiers.shift, i.modifiers.alt) + }); + // The frame the button goes up moves nothing: the scrub ends where the last one left it. + if lead_resp.dragged() { + amount += scrub_amount(dx, step, shift, alt); + ui.data_mut(|d| d.insert_temp(scrub_id, (v0, amount))); + } + let quantum = if alt { step * 0.1 } else { step }; + out = Some(Edit::All(FieldEdit::Scrub(scrubbed(v0, amount, quantum), lead_resp.drag_stopped()))); } } + let (key_step, key_big) = key_steps(ui.ctx(), suffix, step); + // The keyboard is on the field (also in a window that isn't in front: keys reach one only + // through the control channel then). + let on_field = ui.memory(|m| m.has_focus(field_resp.id)); + // What ArrowUp or ArrowDown pressed this frame steps by (Shift: the big step). + let arrow_step = |ui: &Ui| { + ui.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key: egui::Key::ArrowUp, pressed: true, modifiers, .. } => Some(if modifiers.shift { key_big } else { key_step }), + egui::Event::Key { key: egui::Key::ArrowDown, pressed: true, modifiers, .. } => { + Some(-if modifiers.shift { key_big } else { key_step }) + } + _ => None, + }) + }) + }; + // 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)), + }; + // With the keyboard on the field (Tab) and nothing typed yet, the arrows step it as it is. + if editing.is_none() + && on_field + && let Some(by) = arrow_step(ui) + { + out = Some(value.map_or(Edit::Each(Rel::Add(by)), |v| set(stepped(v, 1.0, by)))); + } // Typing on a field the keyboard reached (Tab) edits it: the editor takes the typed text. - let typed = editing.is_none() && field_resp.has_focus() && ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_)))); - let started = editing.is_none() && (field_resp.clicked() || typed); + let typed_now = editing.is_none() && on_field && ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_)))); + let started = editing.is_none() && (field_resp.clicked() || typed_now); + // The text the field would commit to no effect: what it showed when the edit began, or the + // value the arrows last stepped it to. + let kept_id = id.with("kept"); if started { - editing = Some(if typed { String::new() } else { value.map(fmt_num).unwrap_or_default() }); - ui.data_mut(|d| d.insert_temp(editing_id, editing.clone().unwrap_or_default())); + editing = Some(if typed_now { String::new() } else { value.map(fmt_num).unwrap_or_default() }); + ui.data_mut(|d| { + d.insert_temp(editing_id, editing.clone().unwrap_or_default()); + d.insert_temp(kept_id, value.map(fmt_num).unwrap_or_default()); + d.insert_temp(id.with("selectall"), !typed_now); + }); ui.memory_mut(|m| m.request_focus(id.with("te"))); - ui.data_mut(|d| d.insert_temp(id.with("selectall"), !typed)); } match editing { Some(mut text) => { @@ -195,36 +297,42 @@ fn number_field_impl( // The Enter that started the edit (from the keyboard) doesn't end it. let enter = !started && ui.input(|i| i.key_pressed(egui::Key::Enter)); let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); - let arrow = ui.input(|i| { - if i.key_pressed(egui::Key::ArrowUp) { - Some(1.0) - } else if i.key_pressed(egui::Key::ArrowDown) { - Some(-1.0) - } else { - None - } - }); - if r.has_focus() - && let Some(dir) = arrow - && let Some(v) = eval(&text, value) + if ui.memory(|m| m.has_focus(r.id)) + && let Some(by) = arrow_step(ui) { - let shift = ui.input(|i| i.modifiers.shift); - let nv = v + dir * if shift { 10.0 } else { step }; - text = fmt_num(nv); - out = Some(FieldEdit::Set(nv)); + // From what is typed (arithmetic too); no single value: each layer's own steps. + match typed(&text) { + Some(Edit::All(FieldEdit::Set(v))) => { + let nv = stepped(v, 1.0, by); + text = fmt_num(nv); + // The stepped value stays selected: typing replaces it. + ui.data_mut(|d| { + d.insert_temp(kept_id, text.clone()); + d.insert_temp(id.with("selectall"), true); + }); + out = Some(set(nv)); + } + _ if value.is_none() => out = Some(Edit::Each(Rel::Add(by))), + _ => {} + } } if esc { + // Back to what it was: nothing is committed. ui.data_mut(|d| d.remove::(editing_id)); ui.memory_mut(|m| m.surrender_focus(id.with("te"))); } else if r.lost_focus() || enter { - if let Some(v) = eval(&text, value) { - out = Some(FieldEdit::Set(v)); + // Enter, Tab or a click elsewhere commits what was typed (a field left as it was + // commits nothing: no undo step, and a rounded display isn't written back). + let kept: String = ui.data(|d| d.get_temp(kept_id)).unwrap_or_default(); + if text != kept + && let Some(e) = typed(&text) + { + out = Some(e); } ui.data_mut(|d| d.remove::(editing_id)); } else { ui.data_mut(|d| d.insert_temp(editing_id, text)); } - let _ = suffix; } None => { let s = match value { @@ -298,44 +406,6 @@ pub fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> O resp.clicked().then(|| BindEvent::Open(rect.left_bottom())) } -/// Evaluate a typed number: plain, or simple arithmetic relative to `cur` ("+10", "*2", "50%"). -pub fn eval(s: &str, cur: Option) -> Option { - let s = s.trim().trim_end_matches(['%', '°']).trim().replace(',', "."); - if s.is_empty() { - return None; - } - // "+4" adds to the current value ("-4" stays a plain negative number). - if let (Some(rest), Some(c)) = (s.strip_prefix('+'), cur) - && let Ok(v) = rest.trim().parse::() - { - return (c + v).is_finite().then_some(c + v); - } - if let Ok(v) = s.parse::() { - return v.is_finite().then_some(v); - } - // a OP b, or OP b relative to cur. - for op in ['+', '-', '*', '/'] { - if let Some(idx) = s.get(1..).and_then(|r| r.find(op)).map(|i| i + 1).or_else(|| s.starts_with(op).then_some(0)) { - let (a, b) = (s.get(..idx).unwrap_or("").trim(), s.get(idx + 1..).unwrap_or("").trim()); - let a = if a.is_empty() { cur? } else { a.parse::().ok()? }; - let b = b.parse::().ok()?; - let v = match op { - '+' => a + b, - '-' => a - b, - '*' => a * b, - _ => { - if b == 0.0 { - return None; - } - a / b - } - }; - return v.is_finite().then_some(v); - } - } - None -} - /// A text field that commits on Enter / focus loss. Returns the new text when committed. pub fn text_field(ui: &mut Ui, id: Id, value: &str, width: f32, placeholder: &str) -> Option { let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); @@ -974,20 +1044,3 @@ pub fn sublabels(ui: &mut Ui, a: &str, b: Option<&str>) { pub fn col_width(w: f32) -> f32 { ((w - PAD * 2.0 - 8.0 - 32.0) / 2.0).max(40.0) } - -#[cfg(test)] -mod tests { - use super::eval; - - #[test] - fn math_in_fields() { - assert_eq!(eval("12", None), Some(12.0)); - assert_eq!(eval("10+5", None), Some(15.0)); - assert_eq!(eval("*2", Some(8.0)), Some(16.0)); - assert_eq!(eval("+4", Some(8.0)), Some(12.0)); - assert_eq!(eval("-3", None), Some(-3.0)); - assert_eq!(eval("50%", None), Some(50.0)); - assert_eq!(eval("1/0", None), None); - assert_eq!(eval("abc", None), None); - } -} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 311ae5e..fa61d8d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -73,7 +73,7 @@ that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-kn ### `ui.inspect {}` -UI state: panels and tabs, current tool (`tool`: its id, overlay hints and, for a selected +UI state: panels and tabs (`panels`: the `left` and `right` panels' widths as asked for and whether the left one is closed, `leftCollapsed`), the cursor the canvas shows (`cursor`: a CSS cursor name such as `default`, `crosshair`, `ew-resize`, `grab`, `copy`, `not-allowed`, `zoom-in`, or one FigCraft draws: `rotate `, `pen`, `pen-close`, `pen-add`, `pen-bend`, `eyedropper`, `comment`; `null` while the pointer is elsewhere or over something with a cursor of its own, a ruler guide or a gradient handle; the engine's own hint is `tool.overlay.cursor`), current tool (`tool`: its id, overlay hints and, for a selected ellipse, its arc handles `arcHandles`, for a selected rectangle, frame, polygon or star, its corner radius and ratio handles `shapeHandles`; see [mcp.md](mcp.md#measuring-the-scale-tool-arc-handles-and-selecting)), canvas rect, dialogs, view, timings (`frameMs`, `renderMs`), frames drawn since start or the last `ui.set {resetFrameStats: true}` (`framesDrawn`; an idle window draws none) with the most frequent repaint requests behind them (`repaintCauses`: diff --git a/docs/gaps.md b/docs/gaps.md index e833a27..8b601e0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -129,7 +129,11 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe each with its fixes; then keyboard shortcuts per platform (`shortcut_parity_tests.rs`: 129 documented rows, 126 bound, 1 moved off a documented conflict, 2 missing, 48 undocumented bindings pinned), with 16 rows newly bound, Control suspending snapping on macOS and shifted - symbols on other keyboard layouts. + symbols on other keyboard layouts; then the everyday partials of + [`ui-parity.md`](ui-parity.md): number fields, outline mode's options, Hide when publishing, a + cursor for every tool and modifier, panel widths, dark pages (`field_tests`, `cursor_tests`, + `cursors_tests`, `outline_options_tests`, `hide_publishing_tests`, `panel_width_tests`, + `theme_pages_tests`). - **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). diff --git a/docs/mcp.md b/docs/mcp.md index 6fac795..4c87559 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -251,6 +251,20 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: - `view.pan {dx, dy, big?}`: the arrow keys with nothing selected (`key ArrowRight` does the same) pan the view by 20 screen pixels a step, 200 with `big` (Shift). - `view.multiplayerCursors {on?}` (`Alt+Cmd+\`): show or hide others' cursors in a live session. +- `view.outlinesHiddenLayers {on?}` and `view.outlinesObjectBounds {on?}`: what outline mode + (`view.outlines`, `view.outlinesSelection`) draws besides the shapes: hidden layers, and the box + of every layer whose outline isn't its box (groups, text, ellipses, vectors). What they draw can + be clicked (`pointer`) in outline mode. View settings of the file: no undo step. +- `view.panelWidths {left?, right?}`: the app's side panel widths in pixels (kept within their + limits; without either, both go back to the default). A no-op headless. +- `component.hideWhenPublishing {ids?, on?}`: keep components or sets (those `ids`, or the layers + given, are or are in; default the selection) out of the library the file publishes, or let them + back in (`on: false`; without `on` it toggles). The sign of it is the name: hiding puts a `.` + before it, showing takes the leading `.` and `_` away. Returns `{ids, hidden}`; one undo step. +- New files and pages: `file.new` and `page.add` give a page the colour `#F5F5F5`. Only the app + with its dark theme showing makes them `#1E1E1E`; headless sessions (the CLI, this server) have + no theme. Text made with `text.create` is black whatever the page; the Text tool (`pointer` + with `tool.text`) makes it white on a dark page. - `transform.rotationOrigin {on?}` (`Alt+R`): show or hide the selection's rotation origin handle (toggles without `on`); `transform.setRotationOrigin {x, y}` moves the origin (page coordinates), `{reset: true}` puts it back at the centre. Both return `{editing, moved, x, y}`. The corner diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e5ebcad..578ae2c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,9 +2,9 @@ > **Generated:** 2026-10-12 by `cargo xtask parity`. Do not edit by hand. -FigCraft commands registered for the incumbent's main menu and toolbar (528 commands in the engine). +FigCraft commands registered for the incumbent's main menu and toolbar (540 commands in the engine). -**207 / 207 menu items (100.0%)** +**211 / 211 menu items (100.0%)** Every catalog item has a command. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3784d97..0bb055f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping 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; 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-12 · **Change:** minor (everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping 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; 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 @@ -15,7 +15,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Number | Value | Kind | |---|---|---| -| Menu/command breadth | **100%** (207 / 207 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-12) | +| Menu/command breadth | **100%** (211 / 211 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-12) | | Feature breadth (does each Figma Design feature exist, weighted) | **~85%** (core editor areas 1–16: ~94%) | estimated from the area table below | | Ready for real work | **~53%** | 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) | @@ -123,7 +123,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| | Features (depth) | 35 | 63% | 28–67 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | 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) | +| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (modifiers while dragging, hover states, popovers) not yet compared (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not compared); accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | File formats | 12 | 60% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 13% | 52–104 | 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, with cursor chat and spotlight (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | | Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | @@ -625,6 +625,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains | | 2026-10-12 | minor | Text metrics, first measured fidelity data (gap 3): our example documents, exported and re-saved by other software, were read back and every text layer's stored box, line heights, baselines and glyph positions compared with ours. Three rules explained every row and were applied: a line's height is rounded to whole pixels (halves up; Auto, % and px alike; the baseline keeps its fraction inside the rounded line); glyph advances and kerning are each rounded to 1/1024 em before scaling by the font size; letter spacing is not added after a line's last visible glyph. Before: 24 of 131 text boxes of the design-system example matched (118 in width, 24 in height); after: 131 of 131, and all 230 self-sizing text boxes across the 7 documents (Inter only, 11–120 px, letter spacing −6% to 8%, one wrapped paragraph). Glyph positions within 0.001 px: 223 of 1,832 → 1,781; the rest are kerning pairs at weights where the installed font files differ slightly, at most 0.05 px. Frames and other layers matching in size and position: 115 of 231 → 208; the other 23 hang on one grid frame whose row positions came back as written. Not measured: other font families, fractional px line heights, centred or right-aligned text with letter spacing, lists, mixed sizes on a line. Wording only; no number moves | | 2026-10-12 | minor | `.fig` export (gap 1): written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Wording only; no number moves | | 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0b36164..3c53379 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-12 · **Change:** minor (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-12 · **Change:** minor (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: @@ -16,9 +16,9 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| | UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons | -| Light and dark themes | done | colours from `theme::Tokens` | +| Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (text made by a command stays black) (`theme_pages_tests`) | | Minimize UI (Cmd+\\) | done | | -| Resizable panels | partial | check widths and snap points against Figma | +| Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | | Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity | | 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 | @@ -39,7 +39,7 @@ interaction feel not yet compared. | Find layers (filter by name) | done | not covered by the documentation | | Pages: add, rename, drag to reorder, duplicate ("Copy of"), delete, dividers (a blank page named with a hyphen) | done | | | Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | | -| Hide when publishing from the Assets panel's context menu | missing | a leading `.` or `_` in the name only | +| Hide when publishing from the Assets panel's context menu | done | a right-click on one of this file's components (also Libraries ▸ Hide when publishing for the selection, `component.hideWhenPublishing {ids?, on?}`): the model's sign of it is the name, so hiding puts a `.` before the name and showing takes the leading `.` and `_` away (one undo step, saved and synced like a rename; a variant or a layer inside hides its whole component or set); a hidden component shows a crossed eye on its tile and a dimmer name, the menu item its check mark; the next publish leaves it out and brings it back when shown again (`hide_publishing_tests`). Library components (read-only here) have no such menu | ## Selection and transform @@ -55,14 +55,14 @@ interaction feel not yet compared. | Rotation origin (Alt+R) | done | a handle at the selection's centre to drag (snaps to the box's corners, edge middles and centre), double-click puts it back, Escape or Alt+R hides it; the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about it; it follows the layers and lasts as long as the selection, not saved (the documentation doesn't say; pinned) | | Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. No crosshair to move with the arrows first | | Alt-drag duplicate | done | | -| Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`) | +| Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) | | Scale tool (K): handles about an anchor, live factor, panel | done | | | 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 | | 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 | partial | check scrub speed and Shift/Alt steps | +| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | one field widget for every panel (`widgets::number_field`). 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 | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | @@ -93,7 +93,7 @@ interaction feel not yet compared. | Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | | Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only | | Frame labels (rotated, right-to-left), size badge | done | | -| Outline mode | partial | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; the Include hidden layers and Include object bounds options are missing | +| Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on | | Slices shown on the canvas (View ▸ Slices) | done | dashed rectangles | | Dragging a layer inside a group keeps it in the group | done | frames and auto layout take other drops | | Frames and sections drawn around layers take those fully inside; sections never drop into frames | done | not in auto layout frames | @@ -103,7 +103,7 @@ interaction feel not yet compared. | Gamut warnings for Display P3 files | done | colour picker note; View ▸ Soft-proof sRGB stripes what sRGB can't show (the canvas itself is sRGB) | | Gradient and padding/gap handles on the canvas | done | | | Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots | -| Cursors per tool and modifier | partial | not compared | +| Cursors per tool and modifier | done | the engine names a cursor for every tool, modifier and hover target (`tool.overlay.cursor`, tabled in `cursor_tests`) and the UI shows the system's or draws its own at the pointer (`cursors.rs`, `cursors_tests`; original glyphs from lines and the code-drawn icons). Arrow for Move and Scale; two-headed arrows on the handles that follow the handle and the box's rotation; outside a corner a curved rotate arrow turned to that corner, turning with the pointer while rotating; crosshair for the frame, section, slice, shape, pencil and Text tools; the I-beam over a text with the Text tool and over the text being edited; the pen as a cross with what a click does beside it (nothing: a new point; a ring: closes, connects to or goes on from a point; a plus: adds a point on a segment, also Alt in vector edit mode; a curve: bends, the Bend tool or Cmd/Ctrl); open and closed hand for the Hand tool, Space and the middle button; zoom in and out (Alt) while Z is held; eyedropper; comment pin; the copy cursor with Alt over a selected layer and while Alt-dragging; not-allowed while a drag resizes, rotates or moves a layer inside an instance (it is undone on release) and over a layers row that takes no drop. Ruler guides and gradient, crop and padding handles keep their own cursors (the tool's used to replace them). `ui.inspect` reports `cursor`. Left: no cursor for removing a point with the pen (the pen doesn't remove points); the drawn cursors follow the pointer a frame behind the system's; not compared with Figma's side by side | | Multiplayer cursors, cursor chat, spotlight | partial | in live sessions: named cursors, chat bubbles that fade, `/` to chat, spotlight from your avatar's menu with a banner; no audio or reactions | ## Keyboard shortcuts @@ -163,6 +163,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Everyday editing partials: number fields (scrub a step a point, ×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it; arrow keys by the nudge preferences, opacity and rotation by 1 and 10; brackets and units in arithmetic; `50%` relative outside percentage fields; Tab commits and moves on; an untouched field commits nothing; relative edits and arrow keys on Mixed go to each layer in one undo step) done; outline mode's Include hidden layers and Include object bounds, drawn and clickable, done; Hide when publishing from the Assets panel done (the name's leading `.`); cursors per tool and modifier done (a table in the engine, drawn rotate, pen, eyedropper and comment cursors, not-allowed on refused edits; guides and handles keep their cursors); resizable panels done (limits, double-click reset, kept per window and in the preferences, the canvas keeps its room in a small window); a file or page made in the dark theme has a #1E1E1E page (the light theme and sessions without an interface: #F5F5F5) and text drawn on a dark page is white; Enter on a text selects all of it (test added). Menu items: View ▸ Outlines ▸ Include hidden layers / Include object bounds, View ▸ Panels ▸ Reset panel widths, Libraries ▸ Hide when publishing | | 2026-10-12 | minor | Drag threshold and double-clicks: the second press of a double-click began a move or resize that was never finished, so double-clicking text inside a group often didn't start editing (from the middle of the text) or dragged and resized it when the hand moved a pixel or two; a move or resize now starts only after 3 px of travel on screen and a double-click drops the press's gesture first | | 2026-10-12 | minor | Rotation origin and placing with Enter: Alt+R shows a rotation origin handle to drag (snapping to the box's corners, edge middles and centre; double-click resets; Escape hides; kept while the selection lasts, not saved), which the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about (`transform.rotationOrigin`, `transform.setRotationOrigin`); Enter with a shape or frame tool chosen, on the canvas or the toolbar's tool, places a click-sized one in the middle of the view (`tool.place`). Documented shortcuts: 128 bound, 0 missing. The keyboard box selection's toast and screen-reader text name Cmd/Ctrl+arrows too; the shortcuts panel cuts a label that doesn't fit before its keycaps with "…" (measured in the font it's drawn in; long labels ran into the keycaps) and shows it in full on hover | | 2026-10-12 | minor | Keyboard shortcut parity suite and bindings: a table of the 129 documented shortcuts per platform (126 bound, 1 moved for a documented conflict, 2 missing, 2 kept by a platform) plus 48 pinned undocumented bindings; 16 rows newly bound (see Keyboard shortcuts); Ctrl to suspend snapping on macOS done; keyboard box selection on Alt+Space / Ctrl+Space |