diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 267479eb..e4cd6a93 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,7 +27,7 @@ permissions: jobs: windows-arm64: # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 with: app-name: figcraft app-dir: FigCraft diff --git a/ROADMAP.md b/ROADMAP.md index 20e61116..460889a7 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-29 · **Change:** minor (Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -356,6 +356,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | +| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address, with the server key and owner tokens alike (100 renames in a row from one address: 100 served before, 60 now; another address is unaffected), and finds join codes through an index of their hashes instead of opening every link's code. Same results, same protocol. Tests: `admin_budget_tests`. Numbers unchanged | +| 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills (1,000 rectangles filled with one pattern: 1,000 tile drawings before, 1 now, same image fill), and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one, so patterns nested in patterns stop at the budget. Tests: `tile_tests` (svg). Numbers unchanged | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged | diff --git a/apps/figcraft-server/src/abuse.rs b/apps/figcraft-server/src/abuse.rs index 2ea939c3..05eb5122 100644 --- a/apps/figcraft-server/src/abuse.rs +++ b/apps/figcraft-server/src/abuse.rs @@ -1,5 +1,6 @@ //! What one address may do how often: new connections, publishing without the key and messages -//! (only on a server open to the public), and reports and downloads (always). Each is a token +//! (only on a server open to the public), and reports, downloads and requests that change the +//! server's files (always, with the server key too). Each is a token //! bucket per block an address counts in ([`ip_blocks`]: an IPv4 address, or an IPv6 /64 and its //! /56 and /48 with 2 and 4 times the rate), so a tunnel's /48 doesn't get 65,536 /64s' worth. Publishing has a budget for //! everyone besides ([`PUBLISHES_ALL`] times one address's). The table keeps at most @@ -34,6 +35,10 @@ pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64; /// times what the widest block (an IPv6 /48, four addresses' worth) may, so no block takes it all. pub const PUBLISHES_ALL: f64 = 32.0; +/// Requests that change the server's files (join codes, links, renames, removals, blocks, +/// publishing with the key) one address may make a minute, with the server key or an owner token. +pub const ADMIN_PER_MINUTE: f64 = 60.0; + /// Join codes one address may try a minute (right or wrong). pub const CODES_PER_MINUTE: f64 = 10.0; /// Wrong codes in a row (within [`CODE_LOCKOUT`] of each other) that lock an address out of codes. @@ -66,6 +71,8 @@ pub enum Action { Download(usize), /// Trying a join code. Code, + /// A request that changes the server's files ([`ADMIN_PER_MINUTE`]). + Admin, } struct Address { @@ -76,6 +83,7 @@ struct Address { reports: Bucket, downloads: Bucket, codes: Bucket, + admin: Bucket, /// Wrong join codes in a row, and when the last one was. wrong: u32, wrong_at: Instant, @@ -177,6 +185,7 @@ impl Abuse { reports: bucket(REPORTS_PER_HOUR * times, 3600.0), downloads: Bucket::new(DOWNLOAD_RATE * times, DOWNLOAD_BURST * times), codes: bucket(CODES_PER_MINUTE * times, 60.0), + admin: bucket(ADMIN_PER_MINUTE * times, 60.0), wrong: 0, wrong_at: now, locked: None, @@ -195,6 +204,7 @@ impl Abuse { Action::Report => (&mut a.reports, 1.0), Action::Download(n) => (&mut a.downloads, n as f64), Action::Code => (&mut a.codes, 1.0), + Action::Admin => (&mut a.admin, 1.0), } } diff --git a/apps/figcraft-server/src/admin_budget_tests.rs b/apps/figcraft-server/src/admin_budget_tests.rs new file mode 100644 index 00000000..cee514b6 --- /dev/null +++ b/apps/figcraft-server/src/admin_budget_tests.rs @@ -0,0 +1,75 @@ +//! Requests that change the server's files have a budget per address, with the server key or an +//! owner token too; join codes are found through an index of their hashes, with the same results +//! as comparing every link. + +use std::net::IpAddr; + +use figcraft_collab::message::{Admin, AdminReply}; +use figcraft_collab_net::hub::Backend; + +use crate::abuse::ADMIN_PER_MINUTE; +use crate::store::{BUSY_ADMIN, CODE_GONE, Store}; +use crate::test_util::{TempDir, config, ip, snapshot}; + +fn rename(store: &mut Store, from: IpAddr, file: &str) -> AdminReply { + Backend::admin(store, from, Admin::Rename { file: file.into(), name: "Renamed".into() }, &|_| None).reply +} + +#[test] +fn a_flood_of_changes_from_one_address_is_held_back_and_others_are_not() { + let dir = TempDir::new("admin-budget"); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let (file, ..) = store.publish("Poster", &snapshot("Poster")).unwrap(); + let flooder = ip("198.51.100.1"); + let done = (0..100).filter(|_| rename(&mut store, flooder, &file.id) == AdminReply::Done).count(); + assert_eq!(done, ADMIN_PER_MINUTE as usize, "a minute's worth, then wait"); + assert_eq!(rename(&mut store, flooder, &file.id), AdminReply::Failed { reason: BUSY_ADMIN.into() }); + // An owner token's requests share the address's budget. + let hosted = store.hosted(flooder, Some(&file.id), Admin::Rename { file: file.id.clone(), name: "Mine".into() }, &|_| None).reply; + assert_eq!(hosted, AdminReply::Failed { reason: BUSY_ADMIN.into() }); + // Looking costs nothing, and another address has its own budget. + assert!(matches!(Backend::admin(&mut store, flooder, Admin::Files, &|_| None).reply, AdminReply::Files(_))); + assert_eq!(rename(&mut store, ip("198.51.100.2"), &file.id), AdminReply::Done); +} + +#[test] +fn codes_are_found_through_the_index_as_before() { + let dir = TempDir::new("code-index"); + let files_dir = dir.path().join("files"); + let (files, codes) = { + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let mut files = Vec::new(); + let mut codes = Vec::new(); + for n in 0..12 { + let (file, edit, _view) = store.publish(&format!("File {n}"), &snapshot("File")).unwrap(); + codes.push(store.set_code(&file.id, &edit.token).unwrap()); + files.push((file.id, edit.token, edit.link.id)); + } + (files, codes) + }; + // A copy of a file's folder holds the same code (the last file by id wins, as before). + let copy = "zzzzzzzzzzzzzzzz"; + std::fs::create_dir_all(files_dir.join(copy)).unwrap(); + for f in std::fs::read_dir(files_dir.join(&files[0].0)).unwrap() { + let f = f.unwrap(); + std::fs::copy(f.path(), files_dir.join(copy).join(f.file_name())).unwrap(); + } + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let who = |n: usize| ip(&format!("192.0.2.{n}")); + for (n, ((id, token, _), code)) in files.iter().zip(&codes).enumerate().skip(1) { + assert_eq!(store.resolve_code(who(n), code).unwrap(), (id.clone(), token.clone()), "file {n}"); + } + assert_eq!(store.resolve_code(who(100), &codes[0]).unwrap().0, copy); + // Revoking the copy's link leaves the original's code working; revoking that one ends it. + store.revoke(copy, &files[0].2).unwrap(); + assert_eq!(store.resolve_code(who(101), &codes[0]).unwrap(), (files[0].0.clone(), files[0].1.clone())); + store.revoke(&files[0].0, &files[0].2).unwrap(); + assert_eq!(store.resolve_code(who(102), &codes[0]).unwrap_err(), CODE_GONE); + // A new code replaces the old in the index; a removed file's code is gone. + let newer = store.set_code(&files[1].0, &files[1].1).unwrap(); + assert_eq!(store.resolve_code(who(103), &codes[1]).unwrap_err(), CODE_GONE); + assert_eq!(store.resolve_code(who(104), &newer).unwrap().0, files[1].0); + store.remove(&files[2].0).unwrap(); + assert_eq!(store.resolve_code(who(105), &codes[2]).unwrap_err(), CODE_GONE); + assert_eq!(store.resolve_code(who(106), &codes[3]).unwrap().0, files[3].0); +} diff --git a/apps/figcraft-server/src/cli.rs b/apps/figcraft-server/src/cli.rs index f0bd5929..d92689df 100644 --- a/apps/figcraft-server/src/cli.rs +++ b/apps/figcraft-server/src/cli.rs @@ -316,7 +316,8 @@ fn running(data: &Path) -> Result { /// Do one request: through the running server, or on the data directory. fn admin(cfg: &Config, req: Admin) -> Result { match Store::try_open(&cfg.data, cfg.store())? { - Some(mut store) => Ok(store.admin(req, &|_| None).reply), + // On this machine, one request per command: never over the address's budget. + Some(mut store) => Ok(store.admin(std::net::Ipv4Addr::LOCALHOST.into(), req, &|_| None).reply), None => { let addr = running(&cfg.data)?; let key = std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?; diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs index 1e58f7c2..e226d2ec 100644 --- a/apps/figcraft-server/src/codes_tests.rs +++ b/apps/figcraft-server/src/codes_tests.rs @@ -7,11 +7,9 @@ use std::net::IpAddr; use std::time::{Duration, Instant}; -use figcraft_collab::Replica; use figcraft_collab::code; use figcraft_collab::message::{Admin, AdminReply, ClientMsg, Hello, OpenHosting, PROTOCOL, Resolve, ServerMsg}; use figcraft_collab_net::hub::Backend; -use figcraft_doc::{Document, NodeKind}; use figcraft_engine::Session; use figcraft_engine::live::Status; use serde_json::{Value, json}; @@ -20,24 +18,12 @@ use crate::abuse::{Abuse, AbuseConfig, Action, CODE_LOCKOUT, CODES_PER_MINUTE, W use crate::codes::Key; use crate::config::Config; use crate::store::{CODE_GONE, CODES_BUSY, NO_CODE, Store}; -use crate::test_util::{Raw, TempDir, config, live, pump, session}; +use crate::test_util::{Raw, TempDir, config, ip, live, pump, session, snapshot}; fn open(dir: &std::path::Path) -> Config { Config { open_publish: true, operator_contact: "abuse@example.com".into(), terms_url: "https://example.com/terms".into(), ..config(dir) } } -fn snapshot(name: &str) -> Vec { - let mut d = Document::new(name); - let page = d.first_page().unwrap(); - d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap(); - d.repair(); - Replica::new(d).snapshot().unwrap() -} - -fn ip(s: &str) -> IpAddr { - s.parse().unwrap() -} - fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } diff --git a/apps/figcraft-server/src/hardening_tests.rs b/apps/figcraft-server/src/hardening_tests.rs index 92b17710..8226aa3f 100644 --- a/apps/figcraft-server/src/hardening_tests.rs +++ b/apps/figcraft-server/src/hardening_tests.rs @@ -25,24 +25,12 @@ use crate::abuse::{Abuse, AbuseConfig, Action, DOWNLOAD_BURST}; use crate::config::Config; use crate::public_tests::via_proxy; use crate::store::{BUSY_DOWNLOADS, NO_PUBLIC_LIBRARY, Store}; -use crate::test_util::{Raw, TempDir, config}; +use crate::test_util::{Raw, TempDir, config, ip, snapshot}; fn open(dir: &std::path::Path) -> Config { Config { open_publish: true, operator_contact: "abuse@example.com".into(), ..config(dir) } } -fn snapshot(name: &str) -> Vec { - let mut d = Document::new(name); - let page = d.first_page().unwrap(); - d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap(); - d.repair(); - Replica::new(d).snapshot().unwrap() -} - -fn ip(s: &str) -> IpAddr { - s.parse().unwrap() -} - /// A team library archive as the app makes it; with `bomb` bytes of spaces as its snapshot /// (deflated: a few KiB) and `padding` incompressible bytes stored beside it. fn archive(bomb: usize, padding: usize) -> Vec { diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index e2f0d440..746205d1 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -86,6 +86,8 @@ pub fn start(cfg: &Config) -> Result { Ok(Running { hosting }) } +#[cfg(test)] +mod admin_budget_tests; #[cfg(test)] mod background_tests; #[cfg(test)] diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index 37cbabe1..b63f0993 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -6,10 +6,8 @@ use std::io::{Read, Write}; use std::net::{IpAddr, SocketAddr, TcpStream}; use std::time::{Duration, Instant}; -use figcraft_collab::Replica; use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, OpenHosting, PROTOCOL, ServerMsg}; use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage}; -use figcraft_doc::{Document, NodeKind}; use serde_json::{Value, json}; use tungstenite::client::IntoClientRequest; use tungstenite::{Bytes, Message}; @@ -18,24 +16,12 @@ use crate::abuse::{Abuse, AbuseConfig, Action, MAX_ADDRESSES}; use crate::cli::{self, Cli, Command, Sort}; use crate::config::Config; use crate::store::{KEY_ONLY, Store}; -use crate::test_util::{Raw, TempDir, config, join, key, live, pump, session}; +use crate::test_util::{Raw, TempDir, config, ip, join, key, live, pump, session, snapshot}; fn open(dir: &std::path::Path) -> Config { Config { open_publish: true, operator_contact: "abuse@example.com".into(), terms_url: "https://example.com/terms".into(), ..config(dir) } } -fn snapshot(name: &str) -> Vec { - let mut d = Document::new(name); - let page = d.first_page().unwrap(); - d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap(); - d.repair(); - Replica::new(d).snapshot().unwrap() -} - -fn ip(s: &str) -> IpAddr { - s.parse().unwrap() -} - fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 90fce92b..2108bfb8 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -30,7 +30,7 @@ //! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported, //! never deleted. -use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; use std::fs::{self, File, OpenOptions, TryLockError}; use std::io::{Cursor, Write}; use std::net::IpAddr; @@ -88,6 +88,9 @@ const MAX_PROBLEMS: usize = 200; /// Why a download is turned away (an address's share, or everyone's, is used up for now). pub const BUSY_DOWNLOADS: &str = "too many downloads from your address or from everyone right now: try again in a minute"; +/// Why a request that changes the server's files is turned away (the address's share is used up). +pub const BUSY_ADMIN: &str = "too many changes to this server's files from your address: wait a minute and try again"; + /// Why publishing without the key is refused on a server that isn't open to the public. pub const KEY_ONLY: &str = "this server takes files only from people who have its key: add ?key=… to the address (its operator has the key)"; @@ -270,6 +273,9 @@ pub struct Store { // shortcut: in memory only, so after a restart such a code reads as unknown; keep them in the // data directory if people ask why their code "never existed". gone_codes: VecDeque, + /// The files whose links hold each join code, by the code's hash ([`Sealed::hash`]): a typed + /// code is looked up here instead of against every link. + codes: HashMap>, /// When idle files are looked for next (seconds since 1970). next_sweep: u64, /// Compactions so far (tests count them). @@ -396,6 +402,7 @@ impl Store { blocked: BTreeSet::new(), reports: Vec::new(), gone_codes: VecDeque::new(), + codes: HashMap::new(), next_sweep: 0, compactions: 0, cut_short: Vec::new(), @@ -448,6 +455,9 @@ impl Store { } match self.check(&path) { Ok(entry) => { + for c in entry.links.iter().filter_map(|l| l.code.as_ref()) { + self.codes.entry(c.hash.clone()).or_default().insert(id.clone()); + } self.files.insert(id, entry); } Err(why) => self.note(format!("skipped file {id}: {why}")), diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs index 95fd8948..fc36a337 100644 --- a/apps/figcraft-server/src/store/backend.rs +++ b/apps/figcraft-server/src/store/backend.rs @@ -1,9 +1,18 @@ use super::journal::flush_log; use super::{ - Access, Action, Admin, AdminReply, Admission, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, LibraryReply, - LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret, + Access, Action, Admin, AdminReply, Admission, BUSY_ADMIN, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, + LibraryReply, LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret, }; +/// Whether `req` changes the server's files (a write, a removal or a join code's stretching): +/// such requests count against the address's budget ([`Action::Admin`]); looking doesn't. +fn changes_files(req: &Admin) -> bool { + !matches!( + req, + Admin::Files | Admin::Links { .. } | Admin::Listing | Admin::Reports | Admin::Blocked | Admin::Owned { .. } | Admin::RemovePeer { .. } + ) +} + impl Backend for Store { fn admit(&mut self, file: &str, token: &str) -> Result { let entry = self.entry(file)?; @@ -156,15 +165,19 @@ impl Backend for Store { if owns != Some(file.as_str()) { return failed("an owner token manages only its own file"); } - self.admin(req, open) + self.admin(ip, req, open) } fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { self.keep_report(file, link, ip, reason) } - fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { + fn admin(&mut self, ip: IpAddr, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { let failed = |reason: String| Handled::from(AdminReply::Failed { reason }); + if changes_files(&req) && !self.abuse.allow(ip, Action::Admin, Instant::now()) { + log::info!("event=refused ip={ip} reason=admin_per_address"); + return failed(BUSY_ADMIN.into()); + } match req { Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) { Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(), diff --git a/apps/figcraft-server/src/store/links.rs b/apps/figcraft-server/src/store/links.rs index 6c279219..bbdcde7b 100644 --- a/apps/figcraft-server/src/store/links.rs +++ b/apps/figcraft-server/src/store/links.rs @@ -44,18 +44,30 @@ impl Store { found } - /// These join codes no longer open anything: typing one says so. - fn forget_codes(&mut self, codes: impl IntoIterator) { - self.gone_codes.extend(codes.into_iter().map(|c| c.hash)); + /// These join codes of `file` no longer open anything: typing one says so. A code is dropped + /// from the index once none of `file`'s links holds it. + fn forget_codes(&mut self, file: &str, codes: impl IntoIterator) { + for c in codes { + let held = self.files.get(file).is_some_and(|e| e.links.iter().any(|l| l.code.as_ref().is_some_and(|k| k.hash == c.hash))); + if !held && let Some(files) = self.codes.get_mut(&c.hash) { + files.remove(file); + if files.is_empty() { + self.codes.remove(&c.hash); + } + } + self.gone_codes.push_back(c.hash); + } let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES); self.gone_codes.drain(..extra); } /// Where the link with the join code `key` is: its file, its place among the file's links - /// and its token (every code is compared, so timing doesn't tell which one matched). + /// and its token. Only the files the index names for the code's hash are looked at; their + /// links are each compared, in constant time. fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> { let mut found = None; - for (id, e) in &self.files { + for id in self.codes.get(key.hash())? { + let Some(e) = self.files.get(id) else { continue }; for (i, l) in e.links.iter().enumerate() { if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) { found = Some((id.clone(), i, token)); @@ -78,6 +90,7 @@ impl Store { } }; let sealed = key.seal(token)?; + let found_by = sealed.hash.clone(); let path = self.dir(file).join(LINKS); let entry = self.entry(file)?; let link = entry.links.get_mut(at).ok_or("that file has no such link")?; @@ -89,7 +102,8 @@ impl Store { } return Err(format!("couldn't keep the join code: {e}")); } - self.forget_codes(before); + self.codes.entry(found_by).or_default().insert(file.to_string()); + self.forget_codes(file, before); log::info!("event=code file={file} link={id}"); Ok(code) } @@ -137,7 +151,7 @@ impl Store { return Err(format!("couldn't revoke the link: {e}")); } log::info!("revoked link {link} of file {file}"); - self.forget_codes(gone.code); + self.forget_codes(file, gone.code); Ok(()) } @@ -146,7 +160,7 @@ impl Store { pub fn remove(&mut self, file: &str) -> Result<(), String> { // Closed first: an open log would keep the folder from being deleted on Windows. let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?; - self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone())); + self.forget_codes(file, entry.links.iter().filter_map(|l| l.code.clone())); drop(entry); let dir = self.dir(file); fs::remove_dir_all(&dir) diff --git a/apps/figcraft-server/src/test_util.rs b/apps/figcraft-server/src/test_util.rs index 18fe0d86..da5b54a8 100644 --- a/apps/figcraft-server/src/test_util.rs +++ b/apps/figcraft-server/src/test_util.rs @@ -170,3 +170,17 @@ impl Raw { } } } + +/// A one-page file with one rectangle, named `name`, as a snapshot to publish. +pub fn snapshot(name: &str) -> Vec { + let mut d = figcraft_doc::Document::new(name); + let page = d.first_page().unwrap(); + d.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap(); + d.repair(); + figcraft_collab::Replica::new(d).snapshot().unwrap() +} + +/// An address from text. +pub fn ip(s: &str) -> std::net::IpAddr { + s.parse().unwrap() +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index de17cc36..a938bf0f 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -85,6 +85,15 @@ impl eframe::App for WebShell { figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx)); } + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + // Pointer and key events sent over the control channel (`ui.input`), for the frame about + // to draw: the batch is applied by this frame and acknowledged once it is presented, as on + // the desktop (`apps/figcraft/src/main.rs`). Without this hook the batch sat in its queue + // and its promise never settled. + let events = figcraft_ui_egui::windows::take_input(&mut self.fig, raw_input.viewport_id); + raw_input.events.extend(events); + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.fig.control_queue.extend(crate::api::take_requests()); self.fig.ui(ui); diff --git a/contributors/people.toml b/contributors/people.toml index 14c2ad43..77553d44 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -95,3 +95,6 @@ sync_github_name = true [people.StrajnarFilip] display_name = "Filip Strajnar" + +[people.Carbocode] +sync_github_name = true diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 363ecae0..7b548fb0 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -305,7 +305,7 @@ impl Hub { let reply = self.removed_reply(&file, client); self.answer(conn, &ServerMsg::Admin(Box::new(reply))); } - ClientMsg::Admin(req) => self.admin(conn, req), + ClientMsg::Admin(req) => self.admin(conn, ip, req), ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), _ => self.kick(&[conn], "only requests to manage files are spoken on this connection"), }, @@ -538,10 +538,10 @@ impl Hub { } } - fn admin(&mut self, conn: u64, req: Admin) { + fn admin(&mut self, conn: u64, ip: IpAddr, req: Admin) { let rooms = &self.rooms; let open = |file: &str| rooms.get(file).map(Room::info); - let handled = self.backend.admin(req, &open); + let handled = self.backend.admin(ip, req, &open); self.handled(conn, handled); } diff --git a/crates/collab-net/src/hub/backend.rs b/crates/collab-net/src/hub/backend.rs index e594b0f1..929ab6ae 100644 --- a/crates/collab-net/src/hub/backend.rs +++ b/crates/collab-net/src/hub/backend.rs @@ -76,8 +76,9 @@ pub trait Backend: Send + 'static { Err("this session is hosted by an app: it has no files to manage".into()) } - /// A request on a managing connection; `open` tells about a file that is open. - fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { + /// A request on a managing connection from `ip` ([`ip_key`]); `open` tells about a file that + /// is open. + fn admin(&mut self, _ip: IpAddr, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { AdminReply::Failed { reason: "nothing to manage here".into() }.into() } diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 0d4361b2..403620d6 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1337,13 +1337,14 @@ impl ExportSize { (value.is_finite() && value > 0.0 && value <= max).then_some(self) } - /// "2x", "512w", "512h". + /// "2x", "0.125x", "512w", "512h". Not rounded, so `parse` reads it back as the same size. pub fn label(self) -> String { - let n = |v: f64| crate::text::trim_num(v); + // `{}` prints the shortest text that parses back to the same `f64`, and whole numbers + // without a fraction ("2", not "2.0"). match self { - ExportSize::Scale { value } => format!("{}x", n(value)), - ExportSize::Width { value } => format!("{}w", n(value)), - ExportSize::Height { value } => format!("{}h", n(value)), + ExportSize::Scale { value } => format!("{value}x"), + ExportSize::Width { value } => format!("{value}w"), + ExportSize::Height { value } => format!("{value}h"), } } diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index a9cad14a..92ea24cc 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -268,6 +268,12 @@ impl Variables { self.variables.iter().find(|v| v.id == id || v.name == id) } + /// Look up an already resolved variable id (a stored binding or alias): exact ids only, so a + /// variable named like an id never stands in for it. + pub fn get_by_id(&self, id: &str) -> Option<&Variable> { + self.variables.iter().find(|v| v.id == id) + } + pub fn collection(&self, id: &str) -> Option<&Collection> { self.collections.iter().find(|c| c.id == id || c.name == id) } @@ -277,7 +283,7 @@ impl Variables { pub fn resolve(&self, id: &str, modes: &BTreeMap) -> Option { let mut cur = id.to_string(); for _ in 0..32 { - let var = self.get(&cur)?; + let var = self.get_by_id(&cur)?; let coll = self.collection(&var.collection)?; let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?; match var.values.get(mode).or_else(|| var.values.values().next())? { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 70dcd25b..0ed0ef35 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -310,6 +310,42 @@ fn variables_resolve_modes_and_aliases() { assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None); } +#[test] +fn variables_resolve_stored_ids_never_by_name() { + let mut v = Variables::default(); + v.collections.push(Collection { + id: "c1".into(), + name: "Theme".into(), + modes: vec![Mode { id: "m".into(), name: "Mode 1".into() }], + extends: None, + }); + let var = |id: &str, name: &str, value: VariableValue| Variable { + id: id.into(), + name: name.into(), + collection: "c1".into(), + kind: VariableType::Color, + values: std::collections::BTreeMap::from([("m".to_string(), value)]), + description: String::new(), + scopes: None, + hidden_from_publishing: false, + extends: None, + overrides: Default::default(), + code_syntax: Default::default(), + }; + let red = Color::from_hex("#FF0000").unwrap(); + let blue = Color::from_hex("#0D99FF").unwrap(); + // Named like the next variable's id, and like an id that no longer exists. + v.variables.push(var("VariableID:1", "VariableID:2", VariableValue::Color(red))); + v.variables.push(var("VariableID:2", "surface", VariableValue::Color(blue))); + v.variables.push(var("VariableID:3", "VariableID:9", VariableValue::Color(red))); + v.variables.push(var("VariableID:4", "alias", VariableValue::Alias("VariableID:9".into()))); + let modes = std::collections::BTreeMap::new(); + assert_eq!(v.resolve("VariableID:2", &modes), Some(VariableValue::Color(blue))); + // A deleted id stays unresolved instead of picking up the variable named like it. + assert_eq!(v.resolve("VariableID:9", &modes), None); + assert_eq!(v.resolve("VariableID:4", &modes), None); +} + #[test] fn serde_roundtrip_and_repair() { let mut d = Document::new("x"); diff --git a/crates/engine/src/canvas/snap.rs b/crates/engine/src/canvas/snap.rs index de1d2ff0..fab63810 100644 --- a/crates/engine/src/canvas/snap.rs +++ b/crates/engine/src/canvas/snap.rs @@ -9,6 +9,7 @@ use figcraft_geom::{Affine, Point, Rect, Vec2, transform_rect_bbox}; use serde::Serialize; use crate::guides; +use crate::tools::SNAP_PX; /// A guide line to draw: from (x0, y0) to (x1, y1) in page coordinates, with an optional label /// (a distance) at its middle; `dashed` marks a measurement's extension line. @@ -127,6 +128,56 @@ impl Targets { t } + /// Snap lines for a guide on `owner` (a page or a top-level frame): a page's top-level layers, + /// or a frame's own box, layout grids and descendants, in the owner's own space so a rotated + /// frame's guide snaps along its axes. + pub fn gather_for_guide(doc: &Document, page: NodeId, owner: NodeId) -> Targets { + let space = if owner == page { Affine::IDENTITY } else { doc.world_transform(owner) }; + let to = space.inverse(); + let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite()); + let mut t = Targets { space, ..Default::default() }; + if owner == page { + if let Some(p) = doc.node(page) { + for c in &p.children { + if doc.node(*c).is_some_and(|n| n.visible) + && let Some(b) = bbox(*c) + { + t.add_box(b); + } + } + } + return t; + } + // The frame's own box and its layout grids. + if let Some(b) = bbox(owner) { + t.add_box(b); + let (gx, gy) = figcraft_layout::grid_snap_lines_in(doc, owner, to * doc.world_transform(owner)); + for x in gx { + t.xs.push((x, b.y0, b.y1)); + } + for y in gy { + t.ys.push((y, b.x0, b.x1)); + } + } + // Everything in the frame. Nested layers count, so a guide over a group reaches its parts; a + // hidden layer hides its whole branch, so a visible child of a hidden group isn't a target. + let mut stack: Vec<(NodeId, bool)> = doc.node(owner).map(|n| n.children.iter().rev().map(|c| (*c, true)).collect()).unwrap_or_default(); + let mut seen = 0; + while let Some((id, visible)) = stack.pop() { + seen += 1; + if seen > 5_000_000 { + break; + } + let Some(n) = doc.node(id) else { continue }; + let visible = visible && n.visible; + if visible && let Some(b) = bbox(id) { + t.add_box(b); + } + stack.extend(n.children.iter().rev().map(|c| (*c, visible))); + } + t + } + /// A box to snap to (in snap space): its edges and centre lines, and equal spacing with it. pub fn add_box(&mut self, r: Rect) { for x in [r.x0, r.center().x, r.x1] { @@ -335,6 +386,16 @@ impl Targets { (dx, dy, self.guides_spans(&xs, &ys)) } + /// The correction along one axis (x when `vertical`, else y) to the nearest target line within + /// `threshold`, in snap space. A guide moves on its own axis only, so the other is left alone. + pub fn snap_axis(&self, vertical: bool, offset: f64, threshold: f64) -> Option { + if !offset.is_finite() || !threshold.is_finite() || threshold < 0.0 { + return None; + } + let lines = if vertical { &self.xs } else { &self.ys }; + lines.iter().map(|(v, _, _)| v - offset).filter(|d| d.is_finite() && d.abs() <= threshold).min_by(|a, b| a.abs().total_cmp(&b.abs())) + } + /// Guide lines for edges of `r` that coincide with targets. pub fn guides_for(&self, r: Rect) -> Vec { let (xs, ys) = rect_spans(r); @@ -363,6 +424,18 @@ impl Targets { } } +/// Snap a guide on `owner` (a page or a top-level frame) to the nearest layer edge or centre along +/// its own axis, within `SNAP_PX` screen pixels at `zoom`. Returns the new offset in `owner`'s +/// coordinates, or `None` when nothing is in reach. +pub fn snap_guide(doc: &Document, page: NodeId, owner: NodeId, vertical: bool, offset: f64, zoom: f64) -> Option { + // The guide moves along the owner's own axis (x for a vertical guide, y for a horizontal one), + // which the owner's transform may scale: convert the screen-pixel reach into owner units. + let [a, b, c, d, _, _] = doc.world_transform(owner).as_coeffs(); + let axis = if vertical { a.hypot(b) } else { c.hypot(d) }; + let threshold = SNAP_PX / (zoom.max(1e-6) * axis.max(1e-6)); + Targets::gather_for_guide(doc, page, owner).snap_axis(vertical, offset, threshold).map(|d| offset + d).filter(|v| v.is_finite()) +} + /// The parent of the moving layers (the first one's), or the page. fn parent_of(doc: &Document, page: NodeId, moving: &[NodeId]) -> NodeId { moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent).unwrap_or(page) diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index ee5ba4f6..d89b25ae 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -611,7 +611,7 @@ fn apply_corners(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) "cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0), "effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?, "clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?, - "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false), + "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?, "devStatus" => { let st = if v.is_null() { Some(DevStatus::None) @@ -705,7 +705,7 @@ fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) - "contentAlign" | "counterAxisAlignContent" => { layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))? } - "wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false), + "wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?, "gridColumns" | "gridRows" => { let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; let l = layout_mut(cmd, k, doc.get_mut(id)?)?; @@ -714,8 +714,10 @@ fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) - *n = count; tracks.truncate(count as usize); } - "strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false), - "reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false), + "strokesIncluded" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))? + } + "reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?, _ => return Ok(false), } Ok(true) @@ -823,7 +825,7 @@ fn apply_meta(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> "interactions" => doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?, "overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?, "scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?, - "constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false), + "constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?, _ => return Ok(false), } Ok(true) @@ -1004,7 +1006,7 @@ fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> "operandOperation" => set_operand_op(doc, id, v, cmd)?, "sectionContentsHidden" => { if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind { - *contents_hidden = v.as_bool().unwrap_or(false); + *contents_hidden = v.as_bool().ok_or_else(|| bad(cmd, "`sectionContentsHidden` must be a boolean"))?; } } "background" => { diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 6b203c59..b99ba406 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -587,11 +587,14 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result { Ok(json!({"ids": out})) } -/// Infer direction, gap and padding from where children are (Figma's "Add auto layout"). +/// Infer direction, gap and padding from visible, non-absolute children, matching the layout flow. fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout { let Some(f) = doc.node(frame) else { return AutoLayout::default() }; - let boxes: Vec = - f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect(); + let boxes: Vec = figcraft_layout::flow_children(doc, frame) + .iter() + .filter_map(|c| doc.node(*c)) + .map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())) + .collect(); if boxes.is_empty() { return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() }; } @@ -608,12 +611,43 @@ fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout { AutoLayout { mode: if vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, gap, padding: pad, ..Default::default() } } +/// Sort unmasked spans by position along the layout axis. Masks stay at their original indices; +/// each visible mask's following content keeps its paint order until the next mask, hidden or not. +fn sort_layout_children(doc: &Document, kids: &mut [NodeId], vertical: bool) { + let mut start = 0; + let mut masked = false; + for end in 0..=kids.len() { + let mask = kids.get(end).and_then(|id| doc.node(*id)).filter(|n| n.is_mask); + if end != kids.len() && mask.is_none() { + continue; + } + if !masked && let Some(span) = kids.get_mut(start..end) { + span.sort_by(|a, b| { + let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); + let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); + if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) } + }); + } + start = end + 1; + masked = mask.is_some_and(|n| n.visible); + } +} + +/// Add inferred auto layout to existing frames or one group converted in place; wrap other +/// selections. Preserve mask boundaries and a converted group's Fill sizing in its parent. fn layout_add(s: &mut Session, p: &Value) -> Result { let ids = req_targets("layout.add", s, p)?; let dir = string(p, "direction"); let doc = s.doc()?; let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())); - let targets = if all_frames { + let single_group = ids.len() == 1 && ids.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Group)); + let in_place = all_frames || single_group; + let targets = if in_place { + if single_group { + // Layout must arrange the group's children directly. Wrapping the group would + // leave them in a single fixed-size layout item and add an unnecessary layer. + convert(s, &json!({"ids": ids}), "layout.add", true)?; + } ids } else { // Wrap non-frames in a new frame without a fill, then add auto layout to it. @@ -634,23 +668,21 @@ fn layout_add(s: &mut Session, p: &Value) -> Result { _ => LayoutMode::Horizontal, }; } - // Children in reading order along the axis. let mut kids = doc.get(*f)?.children.clone(); - let vertical = al.mode == LayoutMode::Vertical; - kids.sort_by(|a, b| { - let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); - let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); - if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) } - }); + sort_layout_children(doc, &mut kids, al.mode == LayoutMode::Vertical); let empty = doc.node(*f).is_some_and(|n| n.children.iter().filter_map(|c| doc.node(*c)).all(|c| !c.visible || c.child_layout.absolute)); let n = doc.get_mut(*f)?; n.children = kids; n.layout = Some(al); - if !all_frames || !empty { + if !in_place || !empty { // A frame wrapped around the selection hugs it; an existing frame with no flow // children keeps its size instead of collapsing to its padding. - n.child_layout.sizing_h = Sizing::Hug; - n.child_layout.sizing_v = Sizing::Hug; + if !single_group || n.child_layout.sizing_h != Sizing::Fill { + n.child_layout.sizing_h = Sizing::Hug; + } + if !single_group || n.child_layout.sizing_v != Sizing::Fill { + n.child_layout.sizing_v = Sizing::Hug; + } } } s.set_selection(targets.clone()); diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 0f3fdef6..5ccad426 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -784,7 +784,7 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { pub(crate) fn bind_variable(cmd: &str, doc: &mut Document, ids: &[NodeId], field: &str, vid: Option<&str>) -> Result<()> { // An instance's component property takes a variable of its type; other fields the types their // scopes belong to. - if let Some(v) = vid.and_then(|v| doc.variables.get(v)) { + if let Some(v) = vid.and_then(|v| doc.variables.get_by_id(v)) { for i in ids { match field.strip_prefix(crate::components::PROP_BINDING) { Some(name) => super::component::check_prop_binding(doc, *i, name, v.kind).map_err(|e| bad(cmd, e))?, @@ -1253,7 +1253,14 @@ pub(crate) fn set_paint_color(list: &mut Vec, i: usize, stop: Option p.kind = PaintKind::Solid { color: c }, - (None, None) if i == 0 => list.push(Paint::solid(c)), + (None, None) if i == 0 => { + // A bound colour owns its alpha, so the new paint keeps it out of the opacity. + let mut p = Paint::solid(c); + p.kind = PaintKind::Solid { color: c }; + p.opacity = 1.0; + p.color_alpha = 1.0; + list.push(p); + } (None, _) => {} } } diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index b9e73f13..f829785b 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -62,7 +62,7 @@ fn export(s: &mut Session, p: &Value) -> Result { /// `text` as a CSS comment or quoted string body. fn css_safe(text: &str) -> String { - text.replace("*/", "* /").replace(['"', '\\', '\n'], " ") + text.replace("*/", "* /").replace(['"', '\\', '\n', '\r', '\u{c}'], " ") } /// Each collection's variables as custom properties (named as generated code names them): the diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index be338a82..db128293 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -29,6 +29,155 @@ fn kind(s: &Session, n: NodeId) -> NodeKind { s.doc().unwrap().node(n).unwrap().kind.clone() } +#[test] +fn adding_auto_layout_converts_a_single_group_and_arranges_its_children_directly() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let (a, b) = (rect(&mut s, page, 10.0, 20.0), rect(&mut s, page, 90.0, 20.0)); + let group = id(ex(&mut s, "object.group", json!({"ids": [a, b], "name": "Actions"}))); + let siblings = s.doc().unwrap().node(page).unwrap().children.clone(); + let before = [page_box(&s, group), page_box(&s, a), page_box(&s, b)]; + + let result = ex(&mut s, "layout.add", json!({"ids": [group], "direction": "horizontal"})); + assert_eq!(result["ids"], json!([group]), "the group's identity is retained"); + let doc = s.doc().unwrap(); + let frame = doc.node(group).unwrap(); + assert!(matches!(frame.kind, NodeKind::Frame)); + assert_eq!(frame.name, "Actions"); + assert_eq!(frame.parent, Some(page)); + assert_eq!(frame.children, vec![a, b], "no intermediate group or wrapper frame"); + assert!(frame.layout.is_some() && frame.fills.is_empty() && !frame.clip); + assert_eq!(doc.node(page).unwrap().children, siblings); + assert_eq!([page_box(&s, group), page_box(&s, a), page_box(&s, b)], before); + + // The second child now follows changes to the first through auto layout. + ex(&mut s, "node.set", json!({"id": a, "props": {"width": 60}})); + assert_eq!(page_box(&s, b), [110.0, 20.0, 150.0, 40.0]); + s.undo().unwrap(); + s.undo().unwrap(); + let restored = s.doc().unwrap().node(group).unwrap(); + assert!(matches!(restored.kind, NodeKind::Group) && restored.layout.is_none()); + assert_eq!(restored.children, vec![a, b]); + assert_eq!([page_box(&s, group), page_box(&s, a), page_box(&s, b)], before); + s.redo().unwrap(); + assert!(matches!(kind(&s, group), NodeKind::Frame)); + assert!(s.doc().unwrap().node(group).unwrap().layout.is_some()); +} + +#[test] +fn adding_auto_layout_to_multiple_groups_still_wraps_the_selection() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let (a, b) = (rect(&mut s, page, 10.0, 20.0), rect(&mut s, page, 90.0, 20.0)); + let g1 = id(ex(&mut s, "object.group", json!({"ids": [a]}))); + let g2 = id(ex(&mut s, "object.group", json!({"ids": [b]}))); + let result = ex(&mut s, "layout.add", json!({"ids": [g1, g2]})); + let frame = crate::params::id_of(&result["ids"][0]).unwrap(); + let doc = s.doc().unwrap(); + assert!(matches!(doc.node(frame).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(frame).unwrap().children, vec![g1, g2]); + assert!(matches!(kind(&s, g1), NodeKind::Group) && matches!(kind(&s, g2), NodeKind::Group)); +} + +#[test] +fn adding_auto_layout_preserves_mask_runs_and_their_rendering() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let (right, left) = (rect(&mut s, page, 300.0, 0.0), rect(&mut s, page, 0.0, 0.0)); + let (mask, content) = (rect(&mut s, page, 100.0, 0.0), rect(&mut s, page, 80.0, 0.0)); + let (mask2, content2) = (rect(&mut s, page, 200.0, 0.0), rect(&mut s, page, 180.0, 0.0)); + let group = id(ex(&mut s, "object.group", json!({"ids": [right, left, mask, content, mask2, content2]}))); + ex(&mut s, "node.set", json!({"ids": [mask, mask2], "props": {"isMask": true}})); + // Absolute layers keep the overlaps, isolating paint order from normal flow placement. + ex(&mut s, "node.set", json!({"ids": [mask, content, mask2, content2], "props": {"absolute": true}})); + let before = figcraft_render::render_node(s.doc().unwrap(), group, 1.0).unwrap(); + ex(&mut s, "layout.add", json!({"ids": [group]})); + let doc = s.doc().unwrap(); + let frame = doc.node(group).unwrap(); + assert_eq!(frame.children, vec![left, right, mask, content, mask2, content2], "sort the unmasked prefix, retain mask runs"); + let runs: Vec> = doc.paint_runs(frame).iter().map(|r| frame.children[r.clone()].to_vec()).collect(); + assert_eq!(runs, vec![vec![left], vec![right], vec![mask, content], vec![mask2, content2]]); + let after = figcraft_render::render_node(doc, group, 1.0).unwrap(); + assert_eq!((after.width(), after.height(), after.data()), (before.width(), before.height(), before.data())); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().node(group).unwrap().children, vec![right, left, mask, content, mask2, content2]); +} + +#[test] +fn a_hidden_mask_keeps_its_boundary_but_unmasked_children_still_sort() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let mask = rect(&mut s, page, 100.0, 0.0); + let content = rect(&mut s, page, 80.0, 0.0); + let hidden_mask = rect(&mut s, page, 200.0, 0.0); + let (right, left) = (rect(&mut s, page, 300.0, 0.0), rect(&mut s, page, 250.0, 0.0)); + let group = id(ex(&mut s, "object.group", json!({"ids": [mask, content, hidden_mask, right, left]}))); + ex(&mut s, "node.set", json!({"ids": [mask, hidden_mask], "props": {"isMask": true}})); + ex(&mut s, "node.set", json!({"id": hidden_mask, "props": {"visible": false}})); + ex(&mut s, "layout.add", json!({"ids": [group], "direction": "horizontal"})); + let doc = s.doc().unwrap(); + let frame = doc.node(group).unwrap(); + assert_eq!(frame.children, vec![mask, content, hidden_mask, left, right]); + let runs: Vec> = doc.paint_runs(frame).iter().map(|r| frame.children[r.clone()].to_vec()).collect(); + assert_eq!(runs, vec![vec![mask, content], vec![hidden_mask], vec![left], vec![right]]); +} + +#[test] +fn adding_auto_layout_infers_gaps_from_flow_children_only() { + for props in [json!({"visible": false}), json!({"absolute": true})] { + let mut s = Session::new(); + let page = s.page().unwrap(); + let a = rect(&mut s, page, 0.0, 0.0); + let excluded = rect(&mut s, page, 80.0, 0.0); + let b = rect(&mut s, page, 160.0, 0.0); + let group = id(ex(&mut s, "object.group", json!({"ids": [a, excluded, b]}))); + ex(&mut s, "node.set", json!({"id": excluded, "props": props})); + let before = [page_box(&s, a), page_box(&s, b)]; + ex(&mut s, "layout.add", json!({"ids": [group]})); + let layout = s.doc().unwrap().node(group).unwrap().layout.as_ref().unwrap(); + assert_eq!(layout.gap, 120.0); + assert_eq!([page_box(&s, a), page_box(&s, b)], before); + } +} + +#[test] +fn hidden_children_do_not_influence_inferred_direction_or_padding() { + let mut s = Session::new(); + let frame = id(ex(&mut s, "frame.create", json!({"width": 300, "height": 200}))); + let (a, b) = (rect(&mut s, frame, 10.0, 20.0), rect(&mut s, frame, 90.0, 20.0)); + let hidden = rect(&mut s, frame, 0.0, 1000.0); + ex(&mut s, "node.set", json!({"id": hidden, "props": {"visible": false}})); + let before = [page_box(&s, a), page_box(&s, b)]; + ex(&mut s, "layout.add", json!({"ids": [frame]})); + let layout = s.doc().unwrap().node(frame).unwrap().layout.as_ref().unwrap(); + assert_eq!(layout.mode, figcraft_doc::LayoutMode::Horizontal); + assert_eq!(layout.padding, [20.0, 170.0, 160.0, 10.0]); + assert_eq!(layout.gap, 40.0); + assert_eq!([page_box(&s, a), page_box(&s, b)], before); +} + +#[test] +fn converting_a_group_preserves_fill_on_each_axis_in_its_parent() { + for (h, v) in [("fill", "fixed"), ("fixed", "fill"), ("fill", "fill")] { + let mut s = Session::new(); + let parent = id(ex(&mut s, "frame.create", json!({"width": 500, "height": 300}))); + let (a, b) = (rect(&mut s, parent, 0.0, 0.0), rect(&mut s, parent, 80.0, 0.0)); + let group = id(ex(&mut s, "object.group", json!({"ids": [a, b]}))); + let sibling = rect(&mut s, parent, 200.0, 0.0); + ex(&mut s, "node.set", json!({"id": parent, "props": {"layout": "horizontal", "padding": 0, "gap": 10}})); + ex(&mut s, "node.set", json!({"id": group, "props": {"sizingH": h, "sizingV": v}})); + let before = [page_box(&s, group), page_box(&s, sibling)]; + ex(&mut s, "layout.add", json!({"ids": [group]})); + let cl = &s.doc().unwrap().node(group).unwrap().child_layout; + assert_eq!(cl.sizing_h, if h == "fill" { Sizing::Fill } else { Sizing::Hug }); + assert_eq!(cl.sizing_v, if v == "fill" { Sizing::Fill } else { Sizing::Hug }); + assert_eq!([page_box(&s, group), page_box(&s, sibling)], before); + s.undo().unwrap(); + assert!(matches!(kind(&s, group), NodeKind::Group)); + assert_eq!([page_box(&s, group), page_box(&s, sibling)], before); + } +} + #[test] fn a_frame_becomes_a_group_around_its_layers() { let mut s = Session::new(); diff --git a/crates/engine/src/guide_snap_tests.rs b/crates/engine/src/guide_snap_tests.rs new file mode 100644 index 00000000..4c30f129 --- /dev/null +++ b/crates/engine/src/guide_snap_tests.rs @@ -0,0 +1,135 @@ +//! Guides snap to layer edges and centres when dragged out of a ruler or moved, on the guide's own +//! axis only, within five screen pixels at any zoom (page guides see the page's top-level layers; +//! frame guides see the frame's box, layout grids and every layer in it). + +use figcraft_doc::NodeId; +use figcraft_geom::Affine; +use serde_json::json; + +use crate::Session; +use crate::params::id_of; +use crate::snap::{Targets, snap_guide}; + +fn id(v: serde_json::Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +fn page_snap(s: &Session, vertical: bool, offset: f64, zoom: f64) -> Option { + let (doc, page) = (s.doc().unwrap(), s.page().unwrap()); + snap_guide(doc, page, page, vertical, offset, zoom) +} + +#[test] +fn a_page_guide_snaps_to_a_top_level_layers_edges_and_centre() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 300, "width": 50, "height": 40})).unwrap(); + // Edges at 100 and 150, centre at 125; within five pixels at zoom 1. + assert_eq!(page_snap(&s, true, 102.0, 1.0), Some(100.0)); + assert_eq!(page_snap(&s, true, 124.0, 1.0), Some(125.0)); + assert_eq!(page_snap(&s, true, 148.0, 1.0), Some(150.0)); + // Out of reach. + assert_eq!(page_snap(&s, true, 106.0, 1.0), None); + // The guide's own axis only: x 302 is far from the x edges, y 302 is a pixel above 300. + assert_eq!(page_snap(&s, true, 302.0, 1.0), None); + assert_eq!(page_snap(&s, false, 302.0, 1.0), Some(300.0)); +} + +#[test] +fn the_reach_is_five_screen_pixels_at_any_zoom() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 300, "width": 50, "height": 40})).unwrap(); + // Six page pixels away: out of reach at zoom 2 (2.5), in reach at zoom 0.5 (10). + assert_eq!(page_snap(&s, true, 106.0, 2.0), None); + assert_eq!(page_snap(&s, true, 106.0, 0.5), Some(100.0)); + // A zoom of zero is clamped rather than dividing by zero. + assert_eq!(page_snap(&s, true, 100.0, 0.0), Some(100.0)); +} + +#[test] +fn a_frame_guide_snaps_to_its_box_its_children_and_nested_layers() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 50, "width": 300, "height": 200})).unwrap()); + let inner = id(s.execute("node.create", &json!({"type": "frame", "parent": frame, "x": 10, "y": 10, "width": 60, "height": 60})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "parent": inner, "x": 20, "y": 20, "width": 20, "height": 20})).unwrap(); + let outside = id(s.execute("node.create", &json!({"type": "rectangle", "x": 1000, "y": 1000, "width": 20, "height": 20})).unwrap()); + let page = s.page().unwrap(); + let snap = |s: &Session, vertical: bool, offset: f64| snap_guide(s.doc().unwrap(), page, frame, vertical, offset, 1.0); + // The frame's own right edge is 300. + assert_eq!(snap(&s, true, 299.0), Some(300.0)); + // The nested rectangle sits at 30 in the frame; its left edge and centre (40) are targets. + assert_eq!(snap(&s, true, 31.0), Some(30.0)); + assert_eq!(snap(&s, true, 41.0), Some(40.0)); + // The layer outside the frame is not. + assert_eq!(snap(&s, true, 999.0), None); + // Hiding the nested rectangle takes its lines away. + let inner_rect = s.doc().unwrap().node(inner).unwrap().children[0]; + s.execute("node.set", &json!({"ids": [inner_rect], "props": {"visible": false}})).unwrap(); + assert_eq!(snap(&s, true, 31.0), None); + // A layer outside the frame still doesn't matter, and `outside` exists to prove the scope. + assert!(s.doc().unwrap().node(outside).is_some()); +} + +#[test] +fn a_frame_guide_snaps_along_the_frames_own_axes_when_it_is_turned() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 300, "height": 200})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 40, "y": 20, "width": 20, "height": 20})).unwrap(); + s.execute("node.set", &json!({"ids": [frame], "props": {"rotation": 30.0}})).unwrap(); + let (doc, page) = (s.doc().unwrap(), s.page().unwrap()); + let t = Targets::gather_for_guide(doc, page, frame); + assert_ne!(t.space, Affine::IDENTITY, "guides snap in the frame's space"); + assert!(t.xs.iter().any(|(x, _, _)| near(*x, 40.0)), "{:?}", t.xs); + // The rotation rounds the offsets in the last bits, so compare with a tolerance. + assert!(snap_guide(doc, page, frame, true, 41.0, 1.0).is_some_and(|v| near(v, 40.0))); + assert!(snap_guide(doc, page, frame, false, 21.0, 1.0).is_some_and(|v| near(v, 20.0))); +} + +#[test] +fn a_hidden_container_hides_its_children_from_snapping() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 50, "width": 300, "height": 200})).unwrap()); + let inner = id(s.execute("node.create", &json!({"type": "frame", "parent": frame, "x": 10, "y": 10, "width": 60, "height": 60})).unwrap()); + let child = id(s.execute("node.create", &json!({"type": "rectangle", "parent": inner, "x": 20, "y": 20, "width": 20, "height": 20})).unwrap()); + let page = s.page().unwrap(); + let snap = |s: &Session, offset: f64| snap_guide(s.doc().unwrap(), page, frame, true, offset, 1.0); + // The inner frame's left edge is 10 in the frame; the nested rectangle's is 30. + assert_eq!(snap(&s, 11.0), Some(10.0)); + assert_eq!(snap(&s, 31.0), Some(30.0)); + // Hiding the inner frame hides its branch: the child's own flag stays true, but its edge is + // not a target any more (nor is the hidden frame's). + s.execute("node.set", &json!({"ids": [inner], "props": {"visible": false}})).unwrap(); + assert!(s.doc().unwrap().node(child).unwrap().visible); + assert_eq!(snap(&s, 11.0), None); + assert_eq!(snap(&s, 31.0), None); +} + +#[test] +fn the_reach_is_five_screen_pixels_across_a_scaled_frame() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 50, "width": 300, "height": 200})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 100, "y": 10, "width": 20, "height": 20})).unwrap(); + // The frame is drawn at twice its own size, so an owner unit is two screen pixels at zoom 1. + s.doc_mut().unwrap().node_mut(frame).unwrap().transform = Affine::scale(2.0); + let page = s.page().unwrap(); + let snap = |s: &Session, offset: f64, zoom: f64| snap_guide(s.doc().unwrap(), page, frame, true, offset, zoom); + // Five screen pixels are 2.5 frame units: two snaps to the edge at 100, three doesn't. + assert_eq!(snap(&s, 102.0, 1.0), Some(100.0)); + assert_eq!(snap(&s, 103.0, 1.0), None); + // Zooming in halves the owner-space reach again (1.25 frame units at 2x zoom). + assert_eq!(snap(&s, 102.0, 2.0), None); +} + +#[test] +fn a_guide_with_nothing_near_stays_where_it_is() { + let s = Session::new(); + let page = s.page().unwrap(); + let doc = s.doc().unwrap(); + assert_eq!(snap_guide(doc, page, page, true, 12.5, 1.0), None); + assert_eq!(snap_guide(doc, page, page, false, 12.5, 1.0), None); + // Non-finite input is refused rather than spread. + assert_eq!(snap_guide(doc, page, page, true, f64::NAN, 1.0), None); +} diff --git a/crates/engine/src/import_export_tests.rs b/crates/engine/src/import_export_tests.rs index 5560e9fd..a60f85b0 100644 --- a/crates/engine/src/import_export_tests.rs +++ b/crates/engine/src/import_export_tests.rs @@ -232,3 +232,21 @@ fn export_sizes_parse_and_label() { assert_eq!(e(ExportSize::Scale { value: 2.0 }, "-big", ExportFormat::Png).file_suffix(), "-big"); assert_eq!(e(ExportSize::Width { value: 64.0 }, "", ExportFormat::Png).file_suffix(), ""); } + +#[test] +fn export_size_label_parses_back_exactly() { + for want in [ + ExportSize::Scale { value: 0.125 }, + ExportSize::Scale { value: 0.004 }, + ExportSize::Scale { value: 0.001 }, + ExportSize::Scale { value: 63.99 }, + ExportSize::Scale { value: 1.0 / 3.0 }, + ExportSize::Width { value: 512.125 }, + ExportSize::Height { value: 0.005 }, + ] { + assert_eq!(ExportSize::parse(&want.label()), Some(want), "{}", want.label()); + } + assert_eq!(ExportSize::Scale { value: 0.125 }.label(), "0.125x"); + assert_eq!(ExportSize::Scale { value: 2.0 }.label(), "2x"); + assert_eq!(ExportSize::Width { value: 512.0 }.label(), "512w"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bfe47702..efc552c5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -309,6 +309,8 @@ mod gesture_preview_tests; #[cfg(test)] mod gradient_shorthand_tests; #[cfg(test)] +mod guide_snap_tests; +#[cfg(test)] mod hide_publishing_tests; #[cfg(test)] mod history_behaviour_tests; diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index f61bab29..a811806e 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -171,7 +171,9 @@ fn library_update_follows(s: &mut Session, lib: &mut Session, local: &str, [inne // Same copies (same ids), new values: the bound layers and the alias follow. assert_eq!(copy_of(s, "primary").id, local); assert_eq!((fill(s, inner), fill(s, outer)), ("222222".into(), "FF0000".into())); - assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF")); + assert!( + matches!(s.doc().unwrap().variables.resolve(&s.doc().unwrap().variables.get("brand").unwrap().id, &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF") + ); assert_eq!(s.doc().unwrap().variables.collections[0].modes[1].name, "Night"); assert_eq!(ex(s, "library.updates", json!({}))["count"], 0); // Undo takes the update back. @@ -234,7 +236,9 @@ fn bind_alias_mode_read_only_and_updates() { ex(&mut s, "variable.create", json!({"collection": "App", "name": "gap", "type": "number", "value": 2})); ex(&mut s, "library.aliasVariable", json!({"library": lid, "alias": "accent", "variable": "brand"})); let modes = Default::default(); - assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FF00")); + assert!( + matches!(s.doc().unwrap().variables.resolve(&s.doc().unwrap().variables.get("brand").unwrap().id, &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FF00") + ); assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": "gap"})).is_err(), "types differ"); assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": local})).is_err(), "copies are read-only"); diff --git a/crates/engine/src/node_find_tests.rs b/crates/engine/src/node_find_tests.rs index d8729357..a60383be 100644 --- a/crates/engine/src/node_find_tests.rs +++ b/crates/engine/src/node_find_tests.rs @@ -194,3 +194,27 @@ fn node_find_leaves_document_selection_page_and_history_untouched() { assert_ne!(s.doc().unwrap().get(layer).unwrap().name, "Previous edit"); assert_eq!(s.ds().unwrap().history.undo.len(), undo - 1); } + +#[test] +fn node_set_rejects_non_boolean_flags() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let rect = add(&mut s, page, NodeKind::Rectangle, "R"); + let frame = add(&mut s, page, NodeKind::Frame, "F"); + ex(&mut s, "node.set", json!({"ids": [rect], "props": {"isMask": true, "constrainProportions": true}})); + ex(&mut s, "node.set", json!({"ids": [frame], "props": {"layoutMode": "HORIZONTAL", "wrap": true, "strokesIncluded": true, "reverseZ": true}})); + for (id, key) in [(rect, "isMask"), (rect, "constrainProportions"), (frame, "wrap"), (frame, "strokesIncluded"), (frame, "reverseZ")] { + let r = s.execute("node.set", &json!({"ids": [id], "props": {key: "yes"}})); + assert!(r.is_err(), "{key} accepted a non-boolean"); + } + let sec = add(&mut s, page, NodeKind::Section { contents_hidden: false }, "S"); + ex(&mut s, "node.set", json!({"ids": [sec], "props": {"sectionContentsHidden": true}})); + let r = s.execute("node.set", &json!({"ids": [sec], "props": {"sectionContentsHidden": "yes"}})); + assert!(r.is_err(), "sectionContentsHidden accepted a non-boolean"); + let doc = s.doc().unwrap(); + assert!(matches!(doc.get(sec).unwrap().kind, figcraft_doc::NodeKind::Section { contents_hidden: true })); + assert!(doc.get(rect).unwrap().is_mask && doc.get(rect).unwrap().constrain_proportions); + let l = doc.get(frame).unwrap(); + let l = l.layout.as_ref().unwrap(); + assert!(l.wrap && l.strokes_included && l.reverse_z); +} diff --git a/crates/engine/src/variable_props_tests.rs b/crates/engine/src/variable_props_tests.rs index 0d4790d5..2bcb12d5 100644 --- a/crates/engine/src/variable_props_tests.rs +++ b/crates/engine/src/variable_props_tests.rs @@ -373,17 +373,14 @@ fn variable_keys_inside_typed_paint_lists_stay_literal_but_bare_wrappers_error() } #[test] -fn variable_bind_keeps_its_legacy_type_lookup_when_a_name_collides_with_an_id() { +fn variable_bind_type_checks_the_found_variable_when_a_name_collides_with_an_id() { let mut s = Session::new(); run(&mut s, "variable.createCollection", json!({"name": "Theme"})); let number = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "size", "type": "number", "value": 20}))["id"].clone(); let paint = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FF0000"}))["id"].clone(); run(&mut s, "variable.update", json!({"variable": number, "name": paint})); let id = create(&mut s, json!({})); - let before = s.doc().unwrap().clone(); - let depth = s.undo_depth(); - let e = s.execute("variable.bind", &json!({"id": id, "field": "fill", "variable": "surface"})).unwrap_err().to_string(); - assert!(e.contains("`fill` doesn't take a number variable"), "{e}"); - assert_eq!(s.doc().unwrap(), &before); - assert_eq!(s.undo_depth(), depth); + run(&mut s, "variable.bind", json!({"id": id, "field": "fill", "variable": "surface"})); + assert_eq!(s.doc().unwrap().node(id).unwrap().bound.get("fill").map(String::as_str), paint.as_str()); + assert_eq!(color(&s, id, "fill").to_hex(), "FF0000"); } diff --git a/crates/engine/src/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 3fe156f8..7f80a044 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -480,3 +480,21 @@ fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() { assert_eq!(node(&s, t).kind.text().unwrap().characters, "Bye"); assert!(node(&s, t).bound.is_empty()); } + +#[test] +fn a_translucent_variable_bound_to_an_empty_fill_applies_its_alpha_once() { + // #1514: the first paint added by a binding must not take the colour's alpha as opacity too. + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})); + run( + &mut s, + "variable.create", + json!({"collection": "Theme", "name": "tint", "type": "color", "values": {"Light": "#FF000080", "Dark": "#00FF0040"}}), + ); + let r = id(run(&mut s, "node.create", json!({"type": "rectangle", "props": {"fills": []}}))); + run(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "tint"})); + assert_eq!(node(&s, r).fills[0].opacity, 1.0); + run(&mut s, "variable.setMode", json!({"collection": "Theme", "mode": "Dark", "ids": [r]})); + assert_eq!(node(&s, r).fills[0].solid_color().unwrap().to_css_hex(), "#00FF0040"); + assert_eq!(node(&s, r).fills[0].opacity, 1.0); +} diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index e8a0239e..55a2336b 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -513,3 +513,15 @@ fn review_a_long_alias_chain_keeps_its_role_to_the_end() { let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string(); assert!(css.contains("--o0: 0.5;"), "{css}"); } + +#[test] +fn css_export_neutralises_every_css_newline_in_strings() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Brand"})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "t", "type": "string", "value": "a\rb\u{c}c"})); + for format in ["css", "tailwind"] { + let css = ex(&mut s, "variable.export", json!({"format": format}))["text"].as_str().unwrap_or_default().to_string(); + assert!(!css.contains('\r') && !css.contains('\u{c}'), "{css:?}"); + assert!(css.contains("\"a b c\""), "{css:?}"); + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 34a12847..7138d434 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -14,3 +14,5 @@ mod scope_reset; mod selection_colors; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod shape_builder_response; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod variable_binding_ids; diff --git a/crates/engine/tests/it/variable_binding_ids.rs b/crates/engine/tests/it/variable_binding_ids.rs new file mode 100644 index 00000000..cb8b3ab6 --- /dev/null +++ b/crates/engine/tests/it/variable_binding_ids.rs @@ -0,0 +1,32 @@ +use figcraft_engine::Session; +use serde_json::{Value, json}; + +/// Variable A is renamed to variable B's id; returns (session, B's id). +fn renamed_to_other_id(a_type: &str, a_value: Value) -> (Session, String) { + let mut s = Session::new(); + s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap(); + let a = s.execute("variable.create", &json!({"collection": "Theme", "name": "shadow", "type": a_type, "value": a_value})).unwrap(); + let a = a["id"].as_str().unwrap().to_string(); + let b = s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})).unwrap(); + let b = b["id"].as_str().unwrap().to_string(); + s.execute("variable.update", &json!({"variable": a, "name": b})).unwrap(); + (s, b) +} + +#[test] +fn a_bound_id_wins_over_a_variable_named_like_it() { + let (mut s, b) = renamed_to_other_id("color", json!("#FF0000")); + let r = s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": b}}})).unwrap(); + let node = s.execute("node.get", &json!({"id": r["id"]})).unwrap(); + let text = node.to_string(); + assert!(text.contains(&format!("\"boundVariables\":{{\"fill\":\"{b}\"}}")), "{text}"); + assert!(text.contains("#0D99FF"), "{text}"); + assert!(!text.contains("#FF0000"), "{text}"); +} + +#[test] +fn the_type_check_uses_the_bound_id() { + let (mut s, b) = renamed_to_other_id("number", json!(4)); + let r = s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": b}}})); + assert!(r.is_ok(), "{r:?}"); +} diff --git a/crates/format/src/penpot.rs b/crates/format/src/penpot.rs index 554c23b1..bc60f809 100644 --- a/crates/format/src/penpot.rs +++ b/crates/format/src/penpot.rs @@ -5955,12 +5955,14 @@ mod tests { assert_eq!(doc.variables.collections.len(), 1); assert_eq!(doc.variables.variables.len(), 5); let values = &doc.variables; - assert!(matches!(values.resolve("Core/color.primary", &BTreeMap::new()), Some(VariableValue::Color(_)))); - assert_eq!(values.resolve("Core/spacing.sm", &BTreeMap::new()), Some(VariableValue::Number(8.0))); - assert!(matches!(values.resolve("Core/copy.cta", &BTreeMap::new()), Some(VariableValue::String(ref s)) if s == "Buy")); - assert_eq!(values.resolve("Core/flags.enabled", &BTreeMap::new()), Some(VariableValue::Boolean(true))); + assert!(matches!(values.resolve(&values.get("Core/color.primary").unwrap().id, &BTreeMap::new()), Some(VariableValue::Color(_)))); + assert_eq!(values.resolve(&values.get("Core/spacing.sm").unwrap().id, &BTreeMap::new()), Some(VariableValue::Number(8.0))); + assert!( + matches!(values.resolve(&values.get("Core/copy.cta").unwrap().id, &BTreeMap::new()), Some(VariableValue::String(ref s)) if s == "Buy") + ); + assert_eq!(values.resolve(&values.get("Core/flags.enabled").unwrap().id, &BTreeMap::new()), Some(VariableValue::Boolean(true))); assert!(warnings.iter().any(|s| s.contains("themes"))); - assert!(matches!(values.resolve("Core/aliases.secondary", &BTreeMap::new()), Some(VariableValue::Color(_)))); + assert!(matches!(values.resolve(&values.get("Core/aliases.secondary").unwrap().id, &BTreeMap::new()), Some(VariableValue::Color(_)))); assert!(!warnings.iter().any(|s| s.contains("unsupported or unresolved"))); let page = doc.node(doc.first_page().unwrap()).unwrap(); assert_eq!( @@ -6118,9 +6120,9 @@ mod tests { assert_eq!(doc.variables.variables.len(), 7); let v = &doc.variables; assert!(matches!(v.get("Core/palette.middle").unwrap().values.values().next(), Some(VariableValue::Alias(_)))); - assert!(matches!(v.resolve("Core/palette.accent", &BTreeMap::new()), Some(VariableValue::Color(_)))); - assert_eq!(v.resolve("Core/space.copy", &BTreeMap::new()), Some(VariableValue::Number(12.0))); - assert_eq!(v.resolve("Core/labelAlias", &BTreeMap::new()), Some(VariableValue::String("Button".into()))); + assert!(matches!(v.resolve(&v.get("Core/palette.accent").unwrap().id, &BTreeMap::new()), Some(VariableValue::Color(_)))); + assert_eq!(v.resolve(&v.get("Core/space.copy").unwrap().id, &BTreeMap::new()), Some(VariableValue::Number(12.0))); + assert_eq!(v.resolve(&v.get("Core/labelAlias").unwrap().id, &BTreeMap::new()), Some(VariableValue::String("Button".into()))); assert!(!warnings.iter().any(|w| w.contains("unsupported or unresolved")), "{warnings:?}"); let saved = crate::save_bytes(&doc, None).unwrap(); let (reloaded, issues) = crate::load_bytes(&saved).unwrap(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9f2064fc..4625ed7f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -26,7 +26,7 @@ pub mod shadow; pub use dots::Dots; pub use draw::{node_outline, paint_bounds, stroke_fill, subtree_outline, ts_path}; pub use export_depth::{ExportDepthError, MAX_EXPORT_DEPTH, check_export_depth}; -pub use retained::{Retained, Work}; +pub use retained::{Begun, Retained, Steps, Work}; pub use tiny_skia::{FillRule, Pixmap}; use figcraft_color::{Color, ColorProfile}; @@ -89,7 +89,7 @@ pub struct Options { pub dots: Option, } -fn new_pixmap(w: u32, h: u32) -> Option { +pub(crate) fn new_pixmap(w: u32, h: u32) -> Option { let w = w.clamp(1, 16_384); let h = h.clamp(1, 16_384); Pixmap::new(w, h) @@ -112,7 +112,7 @@ pub(crate) fn backdrop_reach(radius: f64, scale: f64) -> f64 { static RENDERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// The number of the render that just started. -fn start_render() -> u64 { +pub(crate) fn start_render() -> u64 { RENDERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 } @@ -146,42 +146,61 @@ pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands: u32) -> Option { use rayon::prelude::*; let band_h = view.height.div_ceil(bands.max(1)); - let overlap = backdrop_reach(max_backdrop_blur(doc), view.scale).ceil(); - if !overlap.is_finite() || overlap > f64::from(band_h) { - return None; - } - let overlap = overlap as u32; - let s = view.scale.max(1e-9); + let overlap = band_overlap(doc, view, band_h)?; let parts: Vec<(u32, Option)> = (0..bands) .into_par_iter() .map(|i| { let y0 = i * band_h; - let h = band_h.min(view.height.saturating_sub(y0)).max(1); - let top = y0.saturating_sub(overlap); - let bottom = (y0 + h + overlap).min(view.height).max(y0 + h); - let v = View { origin: (view.origin.0, view.origin.1 + f64::from(top) / s), scale: view.scale, width: view.width, height: bottom - top }; - let part = render_one(doc, page, &v, opts); - let own = tiny_skia::IntRect::from_xywh(0, (y0 - top) as i32, view.width.clamp(1, 16_384), h); - (y0, if overlap == 0 { part } else { part.zip(own).and_then(|(p, r)| p.clone_rect(r)) }) + (y0, render_band(doc, page, view, opts, y0, band_h, overlap)) }) .collect(); let mut out = new_pixmap(view.width, view.height)?; - let row = out.width() as usize * 4; for (y0, part) in parts { - let Some(part) = part else { continue }; - // Bands are as wide as the view: their rows go straight into place. - let start = y0 as usize * row; - let dst = out.data_mut(); - let n = part.data().len().min(dst.len().saturating_sub(start)); - if part.width() == view.width.clamp(1, 16_384) - && let (Some(d), Some(s)) = (dst.get_mut(start..start + n), part.data().get(..n)) - { - d.copy_from_slice(s); + if let Some(part) = part { + put_band(&mut out, y0, &part); } } Some(out) } +/// How many rows bands of `band_h` rows of `view` overlap (what background blurs read); `None` +/// when that is more than a band. +pub(crate) fn band_overlap(doc: &Document, view: &View, band_h: u32) -> Option { + let overlap = backdrop_reach(max_backdrop_blur(doc), view.scale).ceil(); + (overlap.is_finite() && overlap <= f64::from(band_h)).then_some(overlap as u32) +} + +/// The rows `y0 .. y0 + band_h` of `view` (fewer at the bottom), rendered with `overlap` rows +/// more each way and cut back to their own. +pub(crate) fn render_band(doc: &Document, page: NodeId, view: &View, opts: &Options, y0: u32, band_h: u32, overlap: u32) -> Option { + let s = view.scale.max(1e-9); + let h = band_h.min(view.height.saturating_sub(y0)).max(1); + let top = y0.saturating_sub(overlap); + let bottom = (y0 + h + overlap).min(view.height).max(y0 + h); + let v = View { origin: (view.origin.0, view.origin.1 + f64::from(top) / s), scale: view.scale, width: view.width, height: bottom - top }; + let part = render_one(doc, page, &v, opts); + if overlap == 0 { + return part; + } + let own = tiny_skia::IntRect::from_xywh(0, (y0 - top) as i32, view.width.clamp(1, 16_384), h); + part.zip(own).and_then(|(p, r)| p.clone_rect(r)) +} + +/// Copy a band's rows into `out` at row `y0` (bands are as wide as the view: their rows go +/// straight into place). +pub(crate) fn put_band(out: &mut Pixmap, y0: u32, part: &Pixmap) { + let row = out.width() as usize * 4; + let start = y0 as usize * row; + let width = out.width(); + let dst = out.data_mut(); + let n = part.data().len().min(dst.len().saturating_sub(start)); + if part.width() == width + && let (Some(d), Some(s)) = (dst.get_mut(start..start + n), part.data().get(..n)) + { + d.copy_from_slice(s); + } +} + /// Render a page region on the calling thread. pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option { start_render(); diff --git a/crates/render/src/retained.rs b/crates/render/src/retained.rs index 620b8f56..046ed842 100644 --- a/crates/render/src/retained.rs +++ b/crates/render/src/retained.rs @@ -127,6 +127,70 @@ impl Px { } } +/// What [`Retained::prepare`] found. +enum Prepared { + /// The previous render patched (or reused as is), now kept. + Patched(View, Work), + /// Only a full render will do: the snapped view, its top-left device pixel, the font + /// generation and what [`sensitive`] found when the previous render's document was checked. + Full { snapped: View, px: (i64, i64), fonts: u64, sensitive: Option> }, +} + +/// What [`Retained::begin`] did. +pub enum Begun<'a> { + /// The view drawn, its image and what it took. + Done(View, &'a Pixmap, Work), + /// A full render to draw band by band (boxed: it holds the image). + Steps(Box), +} + +/// A full render drawn a band at a time ([`Retained::begin`]): bands overlap by what background +/// blurs read and keep only their own rows, as the desktop's parallel bands do. +pub struct Steps { + doc: Arc, + page: NodeId, + view: View, + px: (i64, i64), + fonts: u64, + opts: Options, + sensitive: Option>, + pix: Pixmap, + /// The first row not drawn yet. + next: u32, + band_h: u32, + overlap: u32, +} + +impl Steps { + /// Draw the next band; `true` once every row is drawn. + pub fn step(&mut self) -> bool { + if self.done() { + return true; + } + let y0 = self.next; + if let Some(part) = crate::render_band(&self.doc, self.page, &self.view, &self.opts, y0, self.band_h, self.overlap) { + crate::put_band(&mut self.pix, y0, &part); + } + self.next = y0.saturating_add(self.band_h); + self.done() + } + + /// Whether every row is drawn. + pub fn done(&self) -> bool { + self.next >= self.view.height + } + + /// The view drawn (snapped to device pixels). + pub fn view(&self) -> &View { + &self.view + } + + /// The page drawn. + pub fn page(&self) -> NodeId { + self.page + } +} + /// Keeps the last rendered view; see the module docs. #[derive(Default)] pub struct Retained { @@ -142,13 +206,73 @@ impl Retained { /// Render `view` of `page`, reusing the previous render where nothing changed. Returns the /// view actually drawn (snapped to device pixels, see [`snap`]), the image and what it took. pub fn render(&mut self, doc: &Arc, page: NodeId, view: &View, opts: &Options) -> Option<(View, &Pixmap, Work)> { + match self.prepare(doc, page, view, opts)? { + Prepared::Patched(snapped, work) => Some((snapped, &self.last.as_ref()?.pix, work)), + Prepared::Full { snapped, px, fonts, sensitive } => { + let pix = render_view(doc, page, &snapped, opts)?; + let s = sensitive.unwrap_or_else(|| Arc::new(self::sensitive(doc))); + let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); + Some((snapped, &frame.pix, Work::Full)) + } + } + } + + /// [`Self::render`], except that a full render comes back as [`Steps`] to draw `band_h` rows + /// at a time (the browser, whose renders block the page: input is handled between bands) and + /// keep with [`Self::finish`]. A render that is reused or patched is done at once. + pub fn begin(&mut self, doc: &Arc, page: NodeId, view: &View, opts: &Options, band_h: u32) -> Option> { + match self.prepare(doc, page, view, opts)? { + Prepared::Patched(snapped, work) => Some(Begun::Done(snapped, &self.last.as_ref()?.pix, work)), + Prepared::Full { snapped, px, fonts, sensitive } => { + // Bands at least twice what they overlap (each draws its overlap again both ways), + // so a background blur reaching past `band_h` makes the bands taller. + let overlap = crate::band_overlap(doc, &snapped, u32::MAX).map(|o| o.min(snapped.height)); + let band_h = band_h.max(1).max(overlap.unwrap_or(0).saturating_mul(2)); + // A blur that can't be measured: one piece. + let Some(overlap) = overlap else { + let pix = render_view(doc, page, &snapped, opts)?; + let s = sensitive.unwrap_or_else(|| Arc::new(self::sensitive(doc))); + let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); + return Some(Begun::Done(snapped, &frame.pix, Work::Full)); + }; + let pix = crate::new_pixmap(snapped.width, snapped.height)?; + // One render, as the desktop's parallel bands are: glass keeps the shapes a + // later render asks for again, and the bands of this one are not later renders. + crate::start_render(); + Some(Begun::Steps(Box::new(Steps { + doc: doc.clone(), + page, + view: snapped, + px, + fonts, + opts: opts.clone(), + sensitive, + pix, + next: 0, + band_h, + overlap, + }))) + } + } + } + + /// Keep the image `steps` drew (all its bands: see [`Steps::step`]) as a full render. + pub fn finish(&mut self, steps: Steps) -> (View, &Pixmap) { + let Steps { doc, page, view, px, fonts, opts, sensitive, pix, .. } = steps; + let s = sensitive.unwrap_or_else(|| Arc::new(self::sensitive(&doc))); + let frame = self.keep(&doc, page, (&view, px, fonts), &opts, pix, s); + (view, &frame.pix) + } + + /// The previous render patched where it could be (kept, see [`Self::keep`]), or what a full + /// render of `view` needs. + fn prepare(&mut self, doc: &Arc, page: NodeId, view: &View, opts: &Options) -> Option { // A view that can't be put on a pixel grid (non-finite or absurd) draws nothing. let Some((snapped, px)) = snap(view) else { self.last = None; return None; }; let fonts = figcraft_text::layout_generation(); - let mut work = Work::Full; let mut sens = None; if let Some(last) = self.last.take() && last.page == page @@ -173,16 +297,13 @@ impl Retained { let same_doc = Arc::ptr_eq(&last.doc, doc); let s = if same_doc { last.sensitive.clone() } else { Arc::new(sensitive(doc)) }; if let Some((pix, pixels)) = patch(last, doc, page, &snapped, px, opts, &s) { - work = if pixels == 0 { Work::Reused } else { Work::Partial { pixels } }; - let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); - return Some((snapped, &frame.pix, work)); + let work = if pixels == 0 { Work::Reused } else { Work::Partial { pixels } }; + self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); + return Some(Prepared::Patched(snapped, work)); } sens = Some(s); } - let pix = render_view(doc, page, &snapped, opts)?; - let s = sens.unwrap_or_else(|| Arc::new(sensitive(doc))); - let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); - Some((snapped, &frame.pix, work)) + Some(Prepared::Full { snapped, px, fonts, sensitive: sens }) } /// Keep `pix`, the render of `at`: the snapped view, its top-left device pixel and the font diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index 958da373..518033ae 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -287,3 +287,89 @@ fn overflowing_text_is_drawn_while_its_box_is_out_of_view() { let dark = pix.data().as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count(); assert!(dark > 20, "glyphs drawn: {dark}"); } + +/// Draw a full render through [`Retained::begin`] `band_h` rows at a time; how many steps it took. +fn stepped(r: &mut Retained, doc: &Arc, page: NodeId, v: &View, band_h: u32) -> (View, Pixmap, usize) { + match r.begin(doc, page, v, &Options::default(), band_h).unwrap() { + crate::Begun::Done(shown, pix, work) => { + assert_eq!(work, Work::Full, "one piece only when a blur can't be measured"); + (shown, pix.clone(), 0) + } + crate::Begun::Steps(mut steps) => { + let mut n = 1; + while !steps.step() { + n += 1; + assert!(n < 1000, "steps end"); + } + let (shown, pix) = r.finish(*steps); + (shown, pix.clone(), n) + } + } +} + +#[test] +fn a_render_drawn_band_by_band_matches_one_in_one_piece() { + for cut_sensitive in [false, true] { + let s = scene(cut_sensitive); + // The whole scene in view, so the glass's backdrop is never cut by the view. + let v = View { origin: (-10.0, -10.0), scale: 1.0, width: 380, height: 360 }; + let mut r = Retained::default(); + let (shown, pix, n) = stepped(&mut r, &s.doc, s.page, &v, 40); + assert_eq!(n, shown.height.div_ceil(40) as usize, "a band a step"); + let full = render_view(&s.doc, s.page, &shown, &Options::default()).unwrap(); + assert_same(&pix, &full, "bands"); + // Kept as a full render: the next pan patches it. + assert!(partial(check(&mut r, &s.doc, s.page, &View { origin: (0.0, -10.0), ..v }))); + } +} + +#[test] +fn bands_thinner_than_a_blur_reaches_grow_to_twice_its_reach() { + let s = scene(true); + let v = View { origin: (-10.0, -10.0), scale: 4.0, width: 380, height: 360 }; + let reach = crate::band_overlap(&s.doc, &v, u32::MAX).unwrap(); + assert!(reach > 4, "the blur reaches past the bands asked for"); + let mut r = Retained::default(); + let (shown, pix, n) = stepped(&mut r, &s.doc, s.page, &v, 4); + assert_eq!(n, shown.height.div_ceil(4.max(reach.min(shown.height) * 2)) as usize, "bands twice the reach"); + assert_same(&pix, &render_view(&s.doc, s.page, &shown, &Options::default()).unwrap(), "taller bands"); +} + +#[test] +fn a_reused_or_patched_render_needs_no_steps() { + let s = scene(false); + let v = view(0.0, 0.0, 1.0); + let mut r = Retained::default(); + check(&mut r, &s.doc, s.page, &v); + assert!(matches!(r.begin(&s.doc, s.page, &v, &Options::default(), 40), Some(crate::Begun::Done(_, _, Work::Reused)))); + let panned = View { origin: (12.0, 0.0), ..v }; + assert!(matches!(r.begin(&s.doc, s.page, &panned, &Options::default(), 40), Some(crate::Begun::Done(_, _, Work::Partial { .. })))); +} + +/// Background blurs stacked on each other (the upper reads the lower's blurred pixels) across the +/// band boundaries: the bands' overlap, the reach of the largest blur, is enough for the image to +/// match one drawn in one piece, as it is for the desktop's parallel bands. +#[test] +fn stacked_backdrop_blurs_draw_the_same_in_bands() { + fonts(); + let mut d = Document::new("stacked"); + let page = d.first_page().unwrap(); + // Stripes to blur, then three translucent blurred panels overlapping each other. + for i in 0..40 { + let r = d.create(page, NodeKind::Rectangle, "S", 0.0, f64::from(i) * 10.0, 400.0, 5.0).unwrap(); + solid(d.get_mut(r).unwrap(), if i % 2 == 0 { "E03030" } else { "3070E0" }); + } + for (x, y) in [(40.0, 60.0), (120.0, 120.0), (60.0, 200.0)] { + let b = d.create(page, NodeKind::Rectangle, "B", x, y, 220.0, 160.0).unwrap(); + d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::new(1.0, 1.0, 1.0, 0.25))]; + d.get_mut(b).unwrap().effects.push(Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(24.0)), visible: true }); + } + let doc = Arc::new(d); + let v = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 }; + for band_h in [64, 100, 200] { + let mut r = Retained::default(); + let (shown, pix, n) = stepped(&mut r, &doc, page, &v, band_h); + assert!(n >= 3, "{n} bands of {band_h} rows"); + assert_same(&pix, &render_view(&doc, page, &shown, &Options::default()).unwrap(), &format!("bands of {band_h} rows")); + } +} diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index ea9c321f..e16ac4a0 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -34,8 +34,10 @@ mod paint_tests; mod shape_tests; #[cfg(test)] mod structure_tests; +#[cfg(test)] +mod tile_tests; -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use figcraft_color::{BlendMode, ImageScaleMode, Paint}; use figcraft_doc::{Document, Effect, MaskType, Node, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, WindingRule}; @@ -96,11 +98,13 @@ pub(crate) struct Ctx<'a> { fonts: Option, /// Patterns being drawn into tile images (inside one another). pattern_depth: usize, + /// Tiles drawn so far, by pattern and scale (see [`paint`]'s `render_tile`). + tiles: HashMap<(*const usvg::Pattern, u64), Option>, } impl<'a> Ctx<'a> { pub(crate) fn new(doc: &'a mut Document) -> Self { - Ctx { doc, count: 0, anonymous: HashSet::new(), notes: BTreeSet::new(), fonts: None, pattern_depth: 0 } + Ctx { doc, count: 0, anonymous: HashSet::new(), notes: BTreeSet::new(), fonts: None, pattern_depth: 0, tiles: HashMap::new() } } pub(crate) fn full(&self) -> bool { diff --git a/crates/svg/src/import/paint.rs b/crates/svg/src/import/paint.rs index ebee6692..6e6300cc 100644 --- a/crates/svg/src/import/paint.rs +++ b/crates/svg/src/import/paint.rs @@ -228,17 +228,36 @@ fn unstale(root: &usvg::Group) -> (&usvg::Group, Affine) { (g, base) } +/// A pattern tile drawn into an image: the image's hash, its size and its pixels → pattern space +/// transform. +pub(crate) type Tile = (String, f64, f64, Affine); + /// Draws pattern `pat`'s tile into an image (at twice its size on the page, at most [`MAX_TILE`] -/// a side): the image's hash, its size and its pixels → pattern space transform. -fn render_tile(cx: &mut Ctx, pat: &usvg::Pattern, tile: Affine) -> Option<(String, f64, f64, Affine)> { +/// a side), once per pattern and scale in an import: other uses get the same tile. +fn render_tile(cx: &mut Ctx, pat: &usvg::Pattern, tile: Affine) -> Option { if cx.pattern_depth >= 2 { return None; } let r = pat.rect(); let (rw, rh) = (f64::from(r.width()), f64::from(r.height())); let k = (length_scale(tile) * 2.0).max(0.01).min(MAX_TILE / rw.max(rh)); + // The tile depends only on the pattern and the scale; the tree outlives the import, so its + // address names the pattern (ids repeat across embedded documents). + let key = (std::ptr::from_ref(pat), k.to_bits()); + if let Some(t) = cx.tiles.get(&key) { + return t.clone(); + } + let t = draw_tile(cx, pat, k); + cx.tiles.insert(key, t.clone()); + t +} + +/// [`render_tile`]'s drawing at scale `k`, its layers counted in the import's layer budget. +fn draw_tile(cx: &mut Ctx, pat: &usvg::Pattern, k: f64) -> Option { + let r = pat.rect(); + let (rw, rh) = (f64::from(r.width()), f64::from(r.height())); let (pw, ph) = ((rw * k).ceil().max(1.0), (rh * k).ceil().max(1.0)); - if !(pw.is_finite() && ph.is_finite()) || pw > MAX_TILE || ph > MAX_TILE { + if !(pw.is_finite() && ph.is_finite()) || pw > MAX_TILE || ph > MAX_TILE || cx.full() { return None; } let mut sub = Document::new("Pattern"); @@ -247,13 +266,15 @@ fn render_tile(cx: &mut Ctx, pat: &usvg::Pattern, tile: Affine) -> Option<(Strin if let Some(f) = sub.node_mut(frame) { f.fills.clear(); } - let notes = { + let (notes, count) = { let mut scx = Ctx::new(&mut sub); + scx.count = cx.count; scx.pattern_depth = cx.pattern_depth + 1; let (content, base) = unstale(pat.root()); children(&mut scx, content, frame, base, 0); - std::mem::take(&mut scx.notes) + (std::mem::take(&mut scx.notes), scx.count) }; + cx.count = count; cx.notes.extend(notes); let view = figcraft_render::View { origin: (0.0, 0.0), scale: k, width: pw as u32, height: ph as u32 }; let mut pix = figcraft_render::Pixmap::new(pw as u32, ph as u32)?; diff --git a/crates/svg/src/import/tile_tests.rs b/crates/svg/src/import/tile_tests.rs new file mode 100644 index 00000000..42d45cf7 --- /dev/null +++ b/crates/svg/src/import/tile_tests.rs @@ -0,0 +1,83 @@ +//! Pattern tiles: drawn once per pattern and scale in an import, their layers counted in the +//! import's layer budget. + +use figcraft_color::{Paint, PaintKind}; +use figcraft_doc::{Document, NodeKind}; +use figcraft_geom::Affine; + +use super::{Ctx, MAX_NODES, children, import_svg_with_notes}; + +fn svg(defs: &str, body: &str) -> String { + format!(r##"{defs}{body}"##) +} + +/// What importing `data` with `spent` layers of the budget already used leaves behind: the tiles +/// drawn, the layers counted and the notes. +fn walk(data: &str, spent: usize) -> (usize, usize, Vec) { + let tree = usvg::Tree::from_data(data.as_bytes(), &usvg::Options::default()).unwrap(); + let mut d = Document::new("tiles"); + let page = d.first_page().unwrap(); + let frame = d.create(page, NodeKind::Frame, "f", 0.0, 0.0, 100.0, 100.0).unwrap(); + let mut cx = Ctx::new(&mut d); + cx.count = spent; + children(&mut cx, tree.root(), frame, Affine::IDENTITY, 0); + (cx.tiles.len(), cx.count, cx.notes.into_iter().collect()) +} + +const DOTS: &str = r##""##; + +#[test] +fn a_pattern_used_a_thousand_times_is_drawn_once() { + let uses: String = (0..1000).map(|i| format!(r#""#, i % 10 * 10, i / 10)).collect(); + let (tiles, count, _) = walk(&svg(DOTS, &uses), 0); + assert_eq!(tiles, 1); + // A thousand rectangles and the tile's three layers, once. + assert_eq!(count, 1003); + // Another scale is another tile. + let scaled = format!(r#"{uses}"#); + assert_eq!(walk(&svg(DOTS, &scaled), 0).0, 2); +} + +/// The image fills of `data`'s layers, and the images they show. +fn fills(data: &str) -> Vec<(Paint, Vec)> { + let mut d = Document::new("fills"); + let page = d.first_page().unwrap(); + let f = import_svg_with_notes(&mut d, data.as_bytes(), page, 0.0, 0.0, "x").unwrap().frame; + let layers: Vec<_> = d.subtree(f).into_iter().filter_map(|id| d.node(id)).filter_map(|n| n.fills.first().cloned()).collect(); + layers + .into_iter() + .map(|p| { + let PaintKind::Image(ip) = &p.kind else { panic!("not an image fill: {p:?}") }; + let bytes = d.images.get(&ip.image).unwrap().bytes.to_vec(); + (p, bytes) + }) + .collect() +} + +#[test] +fn a_reused_tile_is_the_tile_a_single_use_draws() { + let once = fills(&svg(DOTS, r#""#)); + let twice = fills(&svg(DOTS, r#""#)); + assert_eq!(once.len(), 1); + assert_eq!(twice.len(), 2); + assert!(twice.iter().all(|f| *f == once[0]), "both uses show the single use's tile"); +} + +#[test] +fn a_nested_pattern_bomb_is_refused_within_the_budget() { + // Pattern `a` holds 50 layers, each filled with pattern `b` at its own scale (a tile each), + // and `b` holds 50 layers: 2,601 layers from one rectangle (each of `a`'s layers comes with + // the group carrying its transform). + let b: String = (0..50).map(|_| r#""#).collect(); + let a: String = (1..=50).map(|i| format!(r#""#)).collect(); + let defs = format!( + r#"{b}{a}"# + ); + let data = svg(&defs, r#""#); + // Every tile's layers count. + assert_eq!(walk(&data, 0).1, 2601); + // With 500 layers left the tiles stop there. + let (_, count, notes) = walk(&data, MAX_NODES - 500); + assert!(count <= MAX_NODES, "{count}"); + assert!(notes.iter().any(|n| n.contains("the rest was left out")), "{notes:?}"); +} diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs index c3bb6589..725957f9 100644 --- a/crates/ui-egui/src/canvas/guides.rs +++ b/crates/ui-egui/src/canvas/guides.rs @@ -23,7 +23,8 @@ enum GuideDrag { /// Dragging a new guide out of a ruler. New { vertical: bool, frame: Option }, /// Moving guide `index` of `owner` (a page or frame); `copy` (Alt at the press) drops a new one. - Move { owner: NodeId, index: usize, vertical: bool, copy: bool, frame: Option }, + /// `from` is where it was pressed, so a click without movement only selects it. + Move { owner: NodeId, index: usize, vertical: bool, copy: bool, frame: Option, from: Pos2 }, } impl GuideDrag { @@ -97,23 +98,28 @@ fn place(doc: &Document, owner: NodeId, index: usize, vertical: bool, offset: f6 Some(Placed { owner, index, vertical, offset, line: Some(guides::segment(doc, owner, vertical, offset)?) }) } -/// Guide `index` of `owner` (a page or frame) through page point `p`, its offset rounded to whole -/// pixels with `snap` (else to hundredths). -fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, snap: bool) -> Option { +/// Guide `index` of `owner` (a page or frame) through page point `p`. Its offset snaps to a nearby +/// layer edge or centre when `object_zoom` is set (`None`: object snapping off), then to whole +/// pixels with `pixel` (else to hundredths); a snapped offset keeps the edge exactly. +fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, pixel: bool, object_zoom: Option) -> Option { let ds = app.session.ds().ok()?; let v = guides::offset_at(&ds.doc, owner, vertical, p)?; - let offset = if snap { v.round() } else { (v * 100.0).round() / 100.0 }; + let offset = match object_zoom.and_then(|zoom| figcraft_engine::snap::snap_guide(&ds.doc, ds.page, owner, vertical, v, zoom)) { + Some(snapped) => snapped, + None if pixel => v.round(), + None => (v * 100.0).round() / 100.0, + }; if owner == ds.page { Some(Placed { owner, index, vertical, offset, line: None }) } else { place(&ds.doc, owner, index, vertical, offset) } } /// A guide dragged out of a ruler (`vertical`: the left one) and dropped at page point `p`: on the /// top-level frame under it, in the frame's coordinates and along its axis nearer the ruler's, or /// on the page. -fn drop_target(app: &FigApp, p: Point, vertical: bool, snap: bool) -> Option { +fn drop_target(app: &FigApp, p: Point, vertical: bool, pixel: bool, object_zoom: Option) -> Option { let ds = app.session.ds().ok()?; match guides::drop_owner(&ds.doc, ds.page, p) { - f if f != ds.page => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, snap), - page => through(app, page, usize::MAX, vertical, p, snap), + f if f != ds.page => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, pixel, object_zoom), + page => through(app, page, usize::MAX, vertical, p, pixel, object_zoom), } } @@ -182,18 +188,28 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { return false; } let r = app.canvas.rect; - let (pressed, released, alt) = ctx.input(|i| { - (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary), i.modifiers.alt) + let (pressed, released, alt, suspend) = ctx.input(|i| { + ( + i.pointer.button_pressed(egui::PointerButton::Primary), + i.pointer.button_released(egui::PointerButton::Primary), + i.modifiers.alt, + i.modifiers.command || i.modifiers.ctrl, + ) }); if let Some(drag) = ctx.data(|d| d.get_temp::(drag_id())) { let vertical = match drag { GuideDrag::New { vertical, .. } | GuideDrag::Move { vertical, .. } => vertical, }; let p = app.to_page(pos); - let snap = app.session.ds().map(|d| d.view.snap_pixel).unwrap_or(true); + let (pixel, object_zoom) = + app.session.ds().map(|d| (d.view.snap_pixel, (d.view.snap_objects && !suspend).then_some(d.view.zoom))).unwrap_or((true, None)); let at = match drag { - GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, snap), - GuideDrag::Move { owner, index, vertical, .. } => through(app, owner, index, vertical, p, snap), + GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, pixel, object_zoom), + // A click only selects the guide: object snapping starts once the pointer moves. + GuideDrag::Move { owner, index, vertical, from, .. } => { + let moved = (pos - from).length() > 0.5; + through(app, owner, index, vertical, p, pixel, if moved { object_zoom } else { None }) + } }; // Alt: distances from the guide to the layers in the frame it crosses. let frame = drag.frame().or_else(|| at.map(|g| g.owner).filter(|o| app.session.ds().is_ok_and(|d| d.page != *o))); @@ -272,7 +288,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { app.ui.context_menu_shown = false; } else { ctx.data_mut(|d| { - d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical, copy: alt, frame }) + d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical, copy: alt, frame, from: pos }) }); } } diff --git a/crates/ui-egui/src/canvas/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs index 94b2d3d8..3d7659f5 100644 --- a/crates/ui-egui/src/canvas/render_worker.rs +++ b/crates/ui-egui/src/canvas/render_worker.rs @@ -15,13 +15,16 @@ //! The browser renders on the UI thread, so a zoom's full render blocks the page: there a preview //! is refined only once no newer request has come for [`SETTLE_MS`] (the zoom has paused), or for //! as long as the last full render took when that is shorter (a wait never costs more than the -//! render it may save). +//! render it may save), and the full render is then drawn a band at a time +//! ([`figcraft_render::Steps`]), at least a band a frame and more while they fit in [`STEP_MS`], +//! so input is handled while it draws (on a page whose render takes seconds, a band is a +//! fraction of that: eight at most). use std::sync::Arc; use figcraft_color::ColorProfile; use figcraft_doc::{Document, NodeId}; -use figcraft_render::{Pixmap, Retained, View, Work}; +use figcraft_render::{Begun, Pixmap, Retained, Steps, View, Work}; // A monotonic clock in the browser too: preview decisions rest on how long renders take there. use web_time::Instant; @@ -33,6 +36,17 @@ pub const PREVIEW_AFTER_MS: f64 = 12.0; /// long as the last full render took, when shorter): a full render there blocks the page, and one /// between every two wheel steps of a zoom held it up for seconds. pub const SETTLE_MS: u64 = 120; +/// The browser draws a full render a band at a time ([`figcraft_render::Steps`]): at least one +/// band a frame, and more while they fit in this long, so input is handled while it draws. A +/// band of a render that takes seconds takes a fraction of that (see [`MAX_BANDS`]). +pub const STEP_MS: f64 = 24.0; +/// Bands are at least this many rows ... +const MIN_BAND_ROWS: u32 = 64; +/// ... and a view has at most this many: a frame draws at least one band, so the longest frame of +/// a render drawn in steps is about a band's time, a part of the render rather than the whole of it +/// (0.7–1.4 s where the render took 3.7–4.0 s, measured for #1496; more bands would repeat more of +/// the overlap and of the per-band work). +const MAX_BANDS: u32 = 8; #[derive(Clone)] pub struct Job { @@ -100,11 +114,9 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP } } -/// Render `view` of the job (its own view, or the coarse one standing in for it). -fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option { - let t0 = Instant::now(); - // The canvas is a preview: what's too small to see in detail at this zoom is approximated. - let opts = figcraft_render::Options { +/// The canvas is a preview: what's too small to see in detail at this zoom is approximated. +fn options(job: &Job) -> figcraft_render::Options { + figcraft_render::Options { outline_mode: job.outline, outline: job.outline_extras, outlined: job.outlined.clone(), @@ -114,14 +126,23 @@ fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option< magnified_image_pixels: true, dots: job.dots, ..Default::default() - }; - let (view, pix, work) = retained.render(&job.doc, job.page, view, &opts)?; + } +} + +/// Render `view` of the job (its own view, or the coarse one standing in for it). +fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option { + let t0 = Instant::now(); + let (view, pix, work) = retained.render(&job.doc, job.page, view, &options(job))?; + Some(done(job, view, pix, work, t0.elapsed().as_secs_f64() * 1000.0, coarse)) +} + +/// The image `pix` of `view`, rendered for `job` in `ms`. +fn done(job: &Job, view: View, pix: &Pixmap, work: Work, ms: f64, coarse: bool) -> Done { let mut image = screen_image(pix, job.doc.color_profile); if job.proof { mark_outside_srgb(&mut image, pix, job.doc.color_profile); } - let ms = t0.elapsed().as_secs_f64() * 1000.0; - Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at, seq: 0 }) + Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at, seq: 0 } } /// `view` with [`PREVIEW_FACTOR`] times fewer pixels each way, showing at least the same area. @@ -141,6 +162,56 @@ pub struct Renders { full_ms: f64, /// Previews come first only when renders from scratch take at least this long. preview_after_ms: f64, + /// A full render being drawn band by band (the browser): the job it was started for, the + /// font generation then, the bands and the time they took so far. + steps: Option, +} + +/// A full render drawn in steps and what it is of: a newer job of the same file, page, zoom, size +/// and render options keeps it going (its image is then patched to the job, as a pan or an edit +/// is), any other drops it. +struct Stepping { + job: Job, + fonts: u64, + steps: Box, + spent: f64, +} + +/// Whether `a` and `b` are drawn the same way: the same file, page, zoom, size and options (the +/// view may be elsewhere and the document edited: a render of one is patched to the other). +fn same_settings(a: &Job, b: &Job) -> bool { + a.uid == b.uid + && a.page == b.page + && a.view.scale.to_bits() == b.view.scale.to_bits() + && (a.view.width, a.view.height) == (b.view.width, b.view.height) + && a.outline == b.outline + && a.outline_extras == b.outline_extras + && a.outlined == b.outlined + && a.skip == b.skip + && a.solo == b.solo + && a.proof == b.proof + && a.dots == b.dots +} + +/// Whether a render drawn for `a` (with fonts of generation `fonts`) is `b`'s image as it is: +/// the same settings, document and view, and no fonts arrived meanwhile. +fn same_request(a: &Job, fonts: u64, b: &Job) -> bool { + same_settings(a, b) + && Arc::ptr_eq(&a.doc, &b.doc) + && a.view.origin.0.to_bits() == b.view.origin.0.to_bits() + && a.view.origin.1.to_bits() == b.view.origin.1.to_bits() + && figcraft_text::layout_generation() == fonts +} + +/// What [`Renders::refine_for`] got to. +pub enum Refined { + /// The image (`None`: nothing could be drawn). + Done(Option), + /// Bands remain: call again (next frame). + More, + /// A full render drawn for what has changed since (an edit, a pan or images that came while + /// it was drawn), shown meanwhile; it is drawn again: call again. + Shown(Done), } impl Default for Renders { @@ -152,7 +223,7 @@ impl Default for Renders { impl Renders { /// Previews when renders from scratch take at least `ms` (tests: 0, always). pub fn with_threshold(ms: f64) -> Self { - Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms } + Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms, steps: None } } /// Whether `job` is a render from scratch: the kept image is of another zoom, page or file, or @@ -164,7 +235,13 @@ impl Renders { /// Whether `job` gets a preview first: it renders from scratch and those have been slow on a /// page already drawn (the first render has no measure). fn wants_preview(&self, job: &Job) -> bool { - job.zoom_preview && self.last.is_some() && self.full_ms >= self.preview_after_ms && self.starts_over(job) + job.zoom_preview && self.last.is_some() && self.full_ms_so_far() >= self.preview_after_ms && self.starts_over(job) + } + + /// How long the last render from scratch took, or what the one being drawn in steps has taken + /// so far when that is more: a render dropped mid-way (a zoom going on) leaves a measure too. + pub fn full_ms_so_far(&self) -> f64 { + self.full_ms.max(self.steps.as_ref().map_or(0.0, |s| s.spent)) } /// Whether `job` should get a preview should its render run long: from scratch, with none first. @@ -191,6 +268,107 @@ impl Renders { } done } + + /// [`Self::refine`] for about `budget_ms` at a time (the browser): a full render is drawn band by + /// band over several calls, the kept image (re-projected) or the preview showing meanwhile. + pub fn refine_for(&mut self, job: &Job, budget_ms: f64) -> Refined { + let t0 = Instant::now(); + if let Some(s) = self.steps.as_ref() + && !same_settings(&s.job, job) + { + // Dropped for a render of the new view. What its bands took is at least what a full + // render takes: enough for the next zoom to get its preview and its wait. + self.full_ms = self.full_ms.max(s.spent); + self.steps = None; + } + if self.steps.is_none() + && let Some(r) = self.begin(job, t0, None) + { + return r; + } + let Some(s) = self.steps.as_mut() else { return Refined::Done(None) }; + let steps = &mut s.steps; + let stepped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + loop { + if steps.step() { + return true; + } + if t0.elapsed().as_secs_f64() * 1000.0 >= budget_ms { + return false; + } + } + })); + s.spent += t0.elapsed().as_secs_f64() * 1000.0; + match stepped { + Ok(false) => Refined::More, + Ok(true) => { + let Some(Stepping { job: drawn_for, fonts, steps, spent }) = self.steps.take() else { return Refined::Done(None) }; + let (view, pix) = self.full.finish(*steps); + self.full_ms = spent; + // The image of the job it was started for: its document's colour profile, its + // proof setting and its request time. + let drawn = done(&drawn_for, view, pix, Work::Full, spent, false); + // Drawn for this very request: shown, done. Otherwise kept, then started on like + // any render: an edit, a pan or images that came meanwhile are patched in, or it + // is drawn again in steps (never all at once here), this one shown till then. + if same_request(&drawn_for, fonts, job) { + return Refined::Done(self.finished(job, Some(drawn))); + } + self.begin(job, t0, Some(drawn)).unwrap_or(Refined::More) + } + Err(_) => { + self.steps = None; + self.full.clear(); + Refined::Done(None) + } + } + } + + /// Start `job` on the kept image: done when it is reused or patched, `None` when a full render + /// is now drawn in steps. `drawn` is the kept image just drawn in steps: what a reuse shows, + /// else shown while it is drawn again. + fn begin(&mut self, job: &Job, t0: Instant, mut drawn: Option) -> Option { + // The snapped view may be a row taller. + let rows = job.view.height.saturating_add(1).div_ceil(MAX_BANDS).max(MIN_BAND_ROWS); + let begun = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match self.full.begin(&job.doc, job.page, &job.view, &options(job), rows) { + Some(Begun::Done(view, pix, work)) => Err(Box::new(match drawn.take() { + Some(d) if work == Work::Reused => Some(d), + _ => Some(done(job, view, pix, work, t0.elapsed().as_secs_f64() * 1000.0, false)), + })), + Some(Begun::Steps(steps)) => Ok(steps), + None => Err(Box::new(None)), + })); + match begun { + Ok(Ok(steps)) => { + // A pan or an edit at this zoom builds on this render: no preview for it. + self.last = Some((job.uid, job.page, job.view.scale)); + self.steps = Some(Stepping { job: job.clone(), fonts: figcraft_text::layout_generation(), steps, spent: 0.0 }); + drawn.map(Refined::Shown) + } + Ok(Err(d)) => Some(Refined::Done(self.finished(job, *d))), + Err(_) => { + self.full.clear(); + Some(Refined::Done(None)) + } + } + } + + /// Record a render done at once in [`Self::refine_for`]. + fn finished(&mut self, job: &Job, d: Option) -> Option { + if let Some(d) = &d { + self.last = Some((job.uid, job.page, job.view.scale)); + if d.work == Work::Full { + self.full_ms = d.ms; + } + } + d + } + + /// Whether a full render is being drawn band by band. + pub fn stepping(&self) -> bool { + self.steps.is_some() + } } /// The coarse preview of the whole of `job`'s view, kept in `retained`. @@ -401,7 +579,7 @@ impl Inline { if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) { self.preview_shown = true; self.since = Instant::now(); - let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms / 1000.0); + let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms_so_far() / 1000.0); self.wait = full.map_or(self.settle, |full| full.min(self.settle)); self.ctx.request_repaint_after(self.wait); return Some(answering(d, self.submitted)); @@ -414,8 +592,21 @@ impl Inline { return None; } } - let job = self.pending.take()?; - self.renders.refine(&job).map(|d| answering(d, self.submitted)) + let job = self.pending.as_ref()?; + match self.renders.refine_for(job, STEP_MS) { + Refined::Done(d) => { + self.pending = None; + d.map(|d| answering(d, self.submitted)) + } + Refined::More => { + self.ctx.request_repaint(); + None + } + Refined::Shown(d) => { + self.ctx.request_repaint(); + Some(answering(d, self.submitted)) + } + } } } diff --git a/crates/ui-egui/src/canvas/zoom_preview_tests.rs b/crates/ui-egui/src/canvas/zoom_preview_tests.rs index 68b11870..321016a3 100644 --- a/crates/ui-egui/src/canvas/zoom_preview_tests.rs +++ b/crates/ui-egui/src/canvas/zoom_preview_tests.rs @@ -18,7 +18,7 @@ use super::{CanvasState, shows}; use figcraft_render::Retained; use crate::render_worker::{ - Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now, + Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Refined, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now, }; /// A page of ellipses with a rectangle on top; the session and the rectangle. @@ -90,6 +90,45 @@ fn same_pixels(a: &Done, b: &Done) -> bool { a.image.size == b.image.size && a.image.pixels == b.image.pixels } +/// The same image up to where render bands meet ([`figcraft_render::Steps`] and the desktop's +/// parallel bands cut the view in different places): on each row, under a quarter of a level on +/// average and at most one pixel in twenty off. +fn close_pixels(a: &Done, b: &Done) -> bool { + let [w, h] = a.image.size; + a.image.size == b.image.size + && (0..h).all(|y| { + let row = |d: &Done| d.image.pixels[y * w..(y + 1) * w].to_vec(); + let (ra, rb) = (row(a), row(b)); + let diffs: Vec = + ra.iter().zip(&rb).map(|(p, q)| p.to_array().iter().zip(q.to_array()).map(|(x, y)| u32::from(x.abs_diff(y))).sum()).collect(); + let sum: u32 = diffs.iter().sum(); + f64::from(sum) / (w as f64 * 4.0) < 0.25 && diffs.iter().filter(|d| **d > 0).count() * 20 <= w + }) +} + +/// Poll the browser's worker until it shows the full image (drawn band by band over polls). +fn refined(w: &mut Inline) -> Option { + for _ in 0..1000 { + match w.poll() { + Some(d) => return Some(d), + None if !w.in_flight() => return None, + None => {} + } + } + None +} + +/// Refine `job` through `r` with no time to spare (a band a call); the image and how many calls. +fn stepped(r: &mut Renders, job: &Job) -> (Option, usize) { + for n in 1..1000 { + match r.refine_for(job, 0.0) { + Refined::Done(d) => return (d, n), + Refined::Shown(_) | Refined::More => {} + } + } + (None, 1000) +} + fn covers(outer: &View, inner: &View) -> bool { let (o, i) = (outer.page_rect(), inner.page_rect()); let eps = 1e-6; @@ -397,23 +436,23 @@ fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_fra // The production threshold, as the browser has it; refined the next frame (settling: below). let mut w = Inline::with_settle(egui::Context::default(), std::time::Duration::ZERO); w.submit(at(2.0)); - let first = w.poll().expect("the first image"); + let first = refined(&mut w).expect("the first image"); assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by"); assert!(first.ms >= PREVIEW_AFTER_MS, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms); - // A new zoom: the preview this frame, the refined image the next. + // A new zoom: the preview this frame, the refined image over the next (a band each). w.submit(at(1.0)); let p = w.poll().expect("a preview"); assert!(p.coarse && w.in_flight()); - let d = w.poll().expect("the refined image"); + let d = refined(&mut w).expect("the refined image"); assert!(!d.coarse && !w.in_flight()); - assert!(same_pixels(&d, &render_now(&at(1.0)).unwrap())); + assert!(close_pixels(&d, &render_now(&at(1.0)).unwrap()), "drawn band by band"); // A newer request before the refinement: refining the old one is skipped. w.submit(at(0.5)); assert!(w.poll().is_some_and(|d| d.coarse)); w.submit(at(0.25)); let p = w.poll().expect("the newer one's preview"); assert!(p.coarse && (p.view.scale - 0.25 / PREVIEW_FACTOR).abs() < 1e-12); - assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 0.25)); + assert!(refined(&mut w).is_some_and(|d| !d.coarse && d.view.scale == 0.25)); assert!(w.poll().is_none() && !w.in_flight()); } @@ -426,7 +465,7 @@ fn the_browser_worker_refines_a_zoom_only_once_it_settles() { let settle = std::time::Duration::from_millis(SETTLE_MS); let mut w = Inline::with_settle(egui::Context::default(), settle); w.submit(at(2.0)); - assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait"); + assert!(refined(&mut w).is_some_and(|d| !d.coarse), "the first render: no preview, no wait"); // Zooming: each step shows its preview, never a full render in between. for scale in [1.5, 1.0] { w.submit(at(scale)); @@ -434,11 +473,11 @@ fn the_browser_worker_refines_a_zoom_only_once_it_settles() { assert!(w.poll().is_none() && w.in_flight(), "not refined while the zoom may go on"); } std::thread::sleep(settle); - let d = w.poll().expect("refined once it settled"); + let d = refined(&mut w).expect("refined once it settled"); assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight()); // A pan at the same zoom has no preview and is refined at once. w.submit(job(&ds.doc, ds.page, ds.uid, view_at((340.0, 200.0), 1.0, 1200, 1200))); - assert!(w.poll().is_some_and(|d| !d.coarse)); + assert!(refined(&mut w).is_some_and(|d| !d.coarse)); } /// The wait counts from the preview being shown, not from the request: rendering the preview can @@ -455,13 +494,13 @@ fn the_wait_counts_from_the_preview_not_from_the_request() { let settle = std::time::Duration::from_millis(SETTLE_MS); let mut w = Inline::with_settle(egui::Context::default(), settle); w.submit(at(2.0)); - assert!(w.poll().is_some_and(|d| !d.coarse)); + assert!(refined(&mut w).is_some_and(|d| !d.coarse)); w.submit(at(1.0)); std::thread::sleep(settle + std::time::Duration::from_millis(30)); assert!(w.poll().is_some_and(|d| d.coarse), "the preview, longer after the request than the wait"); assert!(w.poll().is_none() && w.in_flight(), "the wait starts only now"); std::thread::sleep(settle); - assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 1.0)); + assert!(refined(&mut w).is_some_and(|d| !d.coarse && d.view.scale == 1.0)); } /// Until the zoom's full render has run, the kept image is of the old zoom, so a pan or an edit @@ -477,7 +516,7 @@ fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_af let settle = std::time::Duration::from_millis(SETTLE_MS); let mut w = Inline::with_settle(egui::Context::default(), settle); w.submit(at((300.0, 200.0), 2.0)); - assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait"); + assert!(refined(&mut w).is_some_and(|d| !d.coarse), "the first render: no preview, no wait"); // A zoom step, then a pan at the new zoom before the pause: a preview each, no full render. w.submit(at((300.0, 200.0), 1.0)); assert!(w.poll().is_some_and(|d| d.coarse)); @@ -486,11 +525,11 @@ fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_af assert!(w.poll().is_some_and(|d| d.coarse), "from scratch still: the kept image is of the old zoom"); assert!(w.poll().is_none() && w.in_flight(), "the wait starts over"); std::thread::sleep(settle); - let d = w.poll().expect("refined once it settled"); + let d = refined(&mut w).expect("refined once it settled"); assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight()); // From then on a pan at this zoom builds on the kept image: no preview, refined at once. w.submit(at((380.0, 200.0), 1.0)); - let d = w.poll().expect("the refined image"); + let d = refined(&mut w).expect("the refined image"); assert!(!d.coarse && !w.in_flight()); } @@ -507,12 +546,136 @@ fn the_wait_is_capped_by_the_time_of_the_last_full_render() { let settle = std::time::Duration::from_secs(10); let mut w = Inline::with_settle(egui::Context::default(), settle); w.submit(at(2.0)); - let first = w.poll().expect("the first image"); + let first = refined(&mut w).expect("the first image"); assert!(!first.coarse && first.ms >= PREVIEW_AFTER_MS, "slow enough to preview: {} ms", first.ms); w.submit(at(1.0)); assert!(w.poll().is_some_and(|d| d.coarse)); assert!(w.poll().is_none() && w.in_flight(), "not refined in the very next frame"); std::thread::sleep(std::time::Duration::from_secs_f64(first.ms / 1000.0)); - let d = w.poll().expect("refined once the last full render's time has passed, not the settle"); + let d = refined(&mut w).expect("refined once the last full render's time has passed, not the settle"); assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight()); } + +#[test] +fn the_browser_draws_a_full_render_band_by_band() { + let view = |x| view_at((x, 200.0), 1.0, 600, 600); + let s = slow_scene(&[view(300.0)], 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let at = |x| job(&ds.doc, ds.page, ds.uid, view(x)); + let mut r = Renders::default(); + let (d, n) = stepped(&mut r, &at(300.0)); + let d = d.expect("drawn"); + assert!(n > 2 && !r.stepping(), "several calls, then done ({n})"); + assert!(close_pixels(&d, &render_now(&at(300.0)).unwrap())); + // Kept: a pan now patches it at once. + assert!(matches!(r.refine_for(&at(320.0), 0.0), Refined::Done(Some(d)) if d.work != Work::Full)); +} + +#[test] +fn a_pan_while_drawing_finishes_the_render_then_patches_it_and_a_zoom_drops_it() { + let view = |x, scale| view_at((x, 200.0), scale, 600, 600); + let s = slow_scene(&[view(300.0, 1.0)], 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let at = |x, scale| job(&ds.doc, ds.page, ds.uid, view(x, scale)); + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&at(300.0, 1.0), 0.0), Refined::More)); + // Panned meanwhile: the same render goes on, and what comes is the panned view. + let (d, _) = stepped(&mut r, &at(330.0, 1.0)); + let d = d.expect("drawn"); + assert!(close_pixels(&d, &render_now(&at(330.0, 1.0)).unwrap()), "the panned view"); + // Zoomed meanwhile: the render under way is dropped for one of the new zoom. + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&at(300.0, 1.0), 0.0), Refined::More)); + let (d, _) = stepped(&mut r, &at(300.0, 2.0)); + assert_eq!(d.expect("drawn").view.scale, 2.0); +} + +#[test] +fn an_edit_while_drawing_shows_in_what_comes() { + let v = view_at((300.0, 200.0), 1.0, 600, 600); + let mut s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS); + let (doc, page, uid) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.page, ds.uid) + }; + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&job(&doc, page, uid, v.clone()), 0.0), Refined::More)); + // Edited meanwhile, the view unchanged: the render goes on and what comes shows the edit. + s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 400, "height": 400})).unwrap(); + let edited = job(&s.ds().unwrap().doc, page, uid, v); + let (d, _) = stepped(&mut r, &edited); + assert!(close_pixels(&d.expect("drawn"), &render_now(&edited).unwrap()), "the edit shows"); +} + +#[test] +fn a_render_overtaken_by_a_long_pan_is_shown_then_drawn_again() { + let v = view_at((300.0, 200.0), 1.0, 600, 600); + let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&job(&ds.doc, ds.page, ds.uid, v.clone()), 0.0), Refined::More)); + // Panned more than a view meanwhile: nothing to patch. What was drawn shows, then the new view. + let far = job(&ds.doc, ds.page, ds.uid, view_at((1500.0, 200.0), 1.0, 600, 600)); + let mut shown = vec![]; + let last = (0..1000).find_map(|_| match r.refine_for(&far, 0.0) { + Refined::More => None, + Refined::Shown(d) => { + shown.push(d); + None + } + Refined::Done(d) => Some(d), + }); + assert_eq!(shown.len(), 1, "the render drawn shows once"); + assert!(close_pixels(&shown[0], &render_now(&job(&ds.doc, ds.page, ds.uid, v)).unwrap()), "the view it was drawn for"); + assert!(close_pixels(&last.flatten().expect("drawn"), &render_now(&far).unwrap()), "then the new view"); +} + +/// Drawn for the very request, the render is done: it is not started on again, even where the +/// kept image can never be patched (outline mode drawing hidden layers), which drew it again +/// and again, the page never idle. +#[test] +fn a_render_drawn_for_the_request_is_done_even_where_it_could_never_be_patched() { + let v = view_at((300.0, 200.0), 1.0, 600, 600); + let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let mut hidden = job(&ds.doc, ds.page, ds.uid, v); + hidden.outline = true; + hidden.outline_extras.hidden = true; + let mut r = Renders::default(); + let (d, n) = stepped(&mut r, &hidden); + assert!(d.is_some_and(|d| !d.coarse && d.work == Work::Full) && !r.stepping(), "done after {n} calls"); + assert!(n < 100, "drawn once, not over and over ({n} calls)"); +} + +/// A change of render options (outline mode here) mid-way drops the render, as a zoom does: the +/// bands of the old one are not drawn to the end first. +#[test] +fn a_change_of_options_while_drawing_drops_the_render() { + let v = view_at((300.0, 200.0), 1.0, 600, 600); + let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let plain = job(&ds.doc, ds.page, ds.uid, v.clone()); + let mut outlined = plain.clone(); + outlined.outline = true; + let (_, alone) = stepped(&mut Renders::default(), &outlined); + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&plain, 0.0), Refined::More)); + let (d, n) = stepped(&mut r, &outlined); + assert!(d.is_some_and(|d| !d.coarse)); + assert!(n <= alone + 1, "the old render's bands are dropped: {n} calls against {alone} alone"); +} + +/// A render dropped mid-way leaves a measure behind: what its bands took is at least what a full +/// render takes, so the next zoom gets its preview (and, in the browser, its wait) instead of +/// starting from scratch at once, as many times as the zoom goes on. +#[test] +fn a_render_dropped_while_drawing_leaves_its_time_as_the_measure() { + let view = |scale| view_at((300.0, 200.0), scale, 600, 600); + let s = slow_scene(&[view(1.0)], 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale)); + // Previews once renders have taken any time at all: a band has, where no measure (0 ms) hasn't. + let mut r = Renders::with_threshold(1e-9); + assert!(matches!(r.refine_for(&at(1.0), 0.0), Refined::More)); + assert!(r.preview(&at(2.0)).is_some(), "the zoom gets a preview by the dropped render's measure"); +} diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs index c87f4f33..e76f2f57 100644 --- a/crates/ui-egui/src/corner_individual_tests.rs +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -70,6 +70,75 @@ fn ios_button_sets_corner_smoothing_to_60_percent() { assert_eq!(u.app.session.undo_depth().0, history + 1); } +/// The visible smoothing icon uses the same scrub gesture, cursor and modifier rates as the +/// other numeric fields; the whole drag is one undo step, including when clamped at either end. +#[test] +fn corner_smoothing_icon_scrubs_with_modifiers_and_one_undo_step() { + for (dx, modifiers, want) in + [(16.0, Modifiers::NONE, 0.66), (8.0, Modifiers::SHIFT, 1.0), (16.0, Modifiers::ALT, 0.516), (-80.0, Modifiers::NONE, 0.0)] + { + let (mut u, r) = scene(); + u.app.run("node.set", json!({"ids": [r], "props": {"cornerSmoothing": 0.5}})); + let toggle = named_button(&mut u, "Individual corners"); + click(&mut u, toggle); + let lead = u.ctx.read_response(Id::unique("smoothing").with("lead")).expect("smoothing icon").rect; + assert_eq!(lead.width(), 24.0, "the icon has the same drag target as other fields"); + let from = lead.center(); + let out = u.frame_output(vec![Event::PointerMoved(from)]); + assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); + let history = u.app.session.undo_depth().0; + u.frame_with(vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }], modifiers); + for step in 1..=4 { + u.frame_with(vec![Event::PointerMoved(pos2(from.x + dx * step as f32 / 4.0, from.y))], modifiers); + } + let smoothing = |u: &Ui| u.app.session.doc().unwrap().node(r).unwrap().corners.smoothing; + assert!((smoothing(&u) - want).abs() < 1e-9, "the drag updates the canvas before release"); + u.frame_with( + vec![Event::PointerButton { pos: pos2(from.x + dx, from.y), button: egui::PointerButton::Primary, pressed: false, modifiers }], + modifiers, + ); + u.frame(vec![]); + assert!((smoothing(&u) - want).abs() < 1e-9, "release keeps the scrubbed value"); + assert_eq!(u.app.session.undo_depth().0, history + 1); + u.app.session.undo().unwrap(); + assert_eq!(smoothing(&u), 0.5); + u.app.session.redo().unwrap(); + assert!((smoothing(&u) - want).abs() < 1e-9); + } +} + +/// Fine drags keep their fractional baseline across gestures, including a later plain drag. +#[test] +fn consecutive_smoothing_drags_preserve_fractional_percentages() { + let (mut u, r) = scene(); + u.app.run("node.set", json!({"ids": [r], "props": {"cornerSmoothing": 0.5}})); + let toggle = named_button(&mut u, "Individual corners"); + click(&mut u, toggle); + let history = u.app.session.undo_depth().0; + let smoothing = |u: &Ui| u.app.session.doc().unwrap().node(r).unwrap().corners.smoothing; + for (step, (dx, modifiers, want)) in + [(16.0, Modifiers::ALT, 0.516), (16.0, Modifiers::ALT, 0.532), (8.0, Modifiers::NONE, 0.612)].into_iter().enumerate() + { + let from = u.ctx.read_response(Id::unique("smoothing").with("lead")).unwrap().rect.center(); + u.frame_with(vec![Event::PointerMoved(from)], modifiers); + u.frame_with(vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }], modifiers); + for k in 1..=4 { + u.frame_with(vec![Event::PointerMoved(pos2(from.x + dx * k as f32 / 4.0, from.y))], modifiers); + } + u.frame_with( + vec![Event::PointerButton { pos: pos2(from.x + dx, from.y), button: egui::PointerButton::Primary, pressed: false, modifiers }], + modifiers, + ); + u.frame(vec![]); + assert!((smoothing(&u) - want).abs() < 1e-9, "gesture {step}: expected {want}, got {}", smoothing(&u)); + assert_eq!(u.app.session.undo_depth().0, history + step + 1); + } + for want in [0.532, 0.516, 0.5] { + u.app.session.undo().unwrap(); + assert!((smoothing(&u) - want).abs() < 1e-9, "undo restores the precise value"); + } +} + /// The fields sit at their corners, and typing in the bottom right one sets only that corner in a /// single undo step (#1002: the bottom row showed bottom right on the left). #[test] diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 2d8f6287..692673e4 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -148,6 +148,33 @@ fn id(v: serde_json::Value) -> NodeId { NodeId(v["id"].as_u64().unwrap()) } +#[test] +fn layout_add_button_requires_every_selected_container_to_have_no_layout() { + let mut s = Session::new(); + let rect = id(s.execute("shape.rectangle", &json!({"width": 40, "height": 20})).unwrap()); + let group = id(s.execute("object.group", &json!({"ids": [rect]})).unwrap()); + let frame = id(s.execute("frame.create", &json!({"x": 200, "width": 100, "height": 60})).unwrap()); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + for (selection, layout, expected) in [ + (vec![group], false, true), + (vec![frame, group], false, true), + (vec![frame, group], true, false), + (vec![frame], true, false), + (vec![], true, false), + ] { + if layout { + u.app.session.execute("node.set", &json!({"id": frame, "props": {"layout": "horizontal"}})).unwrap(); + } + u.app.session.execute("select.set", &json!({"ids": selection})).unwrap(); + u.frame(vec![]); + let output = u.frame_output(vec![]); + let nodes = output.platform_output.accesskit_update.unwrap().nodes; + let has_add = nodes.iter().any(|(_, n)| n.role() == Role::Button && n.label() == Some("Add auto layout")); + assert_eq!(has_add, expected, "selection: {selection:?}, frame has layout: {layout}"); + } +} + /// 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(); diff --git a/crates/ui-egui/src/guide_snap_tests.rs b/crates/ui-egui/src/guide_snap_tests.rs new file mode 100644 index 00000000..edf8cb11 --- /dev/null +++ b/crates/ui-egui/src/guide_snap_tests.rs @@ -0,0 +1,110 @@ +//! Guides dragged out of a ruler snap to nearby layer edges and centres (Snap to Objects), on the +//! guide's own axis, and Cmd/Ctrl suspends that while the guides keep their pixel snapping. + +use egui::{Event, Modifiers, Pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rulers(mut s: Session) -> Ui { + s.execute("view.rulers", &json!({"on": true})).unwrap(); + Ui::new(s) +} + +fn guides(u: &Ui, owner: NodeId) -> Vec<(bool, f64)> { + let ds = u.app.session.ds().unwrap(); + ds.doc.node(owner).unwrap().guides().iter().map(|g| (g.vertical, g.offset)).collect() +} + +fn page(u: &Ui) -> NodeId { + u.app.session.ds().unwrap().page +} + +fn from_top_ruler(u: &Ui) -> Pos2 { + let r = u.app.canvas.rect; + Pos2::new(r.min.x + r.width() * 0.5, r.min.y + 5.0) +} + +fn from_left_ruler(u: &Ui) -> Pos2 { + let r = u.app.canvas.rect; + Pos2::new(r.min.x + 5.0, r.min.y + r.height() * 0.5) +} + +fn drag(u: &mut Ui, from: Pos2, to: Pos2) { + u.frame(vec![Event::PointerMoved(from)]); + u.button(from, true); + u.frame(vec![Event::PointerMoved(to)]); + u.button(to, false); +} + +fn drag_with(u: &mut Ui, from: Pos2, to: Pos2, m: Modifiers) { + u.frame_with(vec![Event::PointerMoved(from)], m); + u.frame_with(vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: m }], m); + u.frame_with(vec![Event::PointerMoved(to)], m); + u.frame_with(vec![Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: m }], m); +} + +#[test] +fn a_guide_dragged_from_a_ruler_snaps_to_a_layers_edge() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap(); + let mut u = rulers(s); + let start = from_top_ruler(&u); + let to = u.app.to_screen(200.0, 102.0); + drag(&mut u, start, to); + assert_eq!(guides(&u, page(&u)), vec![(false, 100.0)], "two pixels from the top edge"); +} + +#[test] +fn a_guide_dragged_over_a_frame_snaps_to_a_layer_inside_it() { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 300, "height": 200})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + let mut u = rulers(s); + let start = from_left_ruler(&u); + let to = u.app.to_screen(112.0, 200.0); + drag(&mut u, start, to); + assert_eq!(guides(&u, frame), vec![(true, 10.0)], "two pixels from the child's left edge"); +} + +#[test] +fn with_snap_to_objects_off_the_guide_keeps_its_pixel_rounding() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap(); + s.execute("view.snapObjects", &json!({"on": false})).unwrap(); + let mut u = rulers(s); + let start = from_top_ruler(&u); + let to = u.app.to_screen(200.0, 102.0); + drag(&mut u, start, to); + assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)], "rounded to the pixel, not the edge"); +} + +#[test] +fn a_click_on_a_guide_only_selects_it() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "offset": 102})).unwrap(); + let mut u = rulers(s); + // Two pixels above the rectangle's top edge, but a click without movement leaves it there. + let at = u.app.to_screen(500.0, 102.0); + u.button(at, true); + u.button(at, false); + assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)], "a click must not snap it to the edge at 100"); +} + +#[test] +fn cmd_or_ctrl_suspends_guide_snapping() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap(); + let mut u = rulers(s); + let start = from_top_ruler(&u); + let to = u.app.to_screen(200.0, 102.0); + drag_with(&mut u, start, to, Modifiers::CTRL); + assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)]); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index eeacb275..a891abed 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -89,6 +89,8 @@ icons! { FlipH, FlipV, CornerRadius, + /// A smooth corner with its two tangent endpoints. + CornerSmoothing, /// Individual corners: the rounded corner each field sets. CornerTopLeft, CornerTopRight, diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs index 7eedfdd1..953ef2de 100644 --- a/crates/ui-egui/src/icons/draw.rs +++ b/crates/ui-egui/src/icons/draw.rs @@ -510,6 +510,18 @@ fn draw(p: &P, icon: Icon) { p.fill(&[(3.0, 9.0), (13.0, 9.0), (13.0, 14.0)]); } CornerRadius | CornerTopLeft => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0), + CornerSmoothing => { + // An original superellipse corner, with dots marking its tangent endpoints. + let points: Vec<_> = (0..=24) + .map(|i| { + let a = i as f32 / 24.0 * FRAC_PI_2; + (2.5 + 11.0 * (1.0 - a.cos().max(0.0).sqrt()), 2.5 + 11.0 * (1.0 - a.sin().max(0.0).sqrt())) + }) + .collect(); + p.path(&points, false); + p.dot(2.5, 13.5, 1.0); + p.dot(13.5, 2.5, 1.0); + } CornerTopRight => p.corner(13.5, 2.5, -1.0, 1.0, 11.5, 6.0), CornerBottomRight => p.corner(13.5, 13.5, -1.0, -1.0, 11.5, 6.0), CornerBottomLeft => p.corner(2.5, 13.5, 1.0, -1.0, 11.5, 6.0), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cef6515b..c12eaf17 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -95,6 +95,8 @@ mod gradient_shorthand_tests; #[cfg(test)] mod guide_release_tests; #[cfg(test)] +mod guide_snap_tests; +#[cfg(test)] mod home_recents_tests; #[cfg(test)] mod home_tests; diff --git a/crates/ui-egui/src/panels/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs index 34516477..b21e074c 100644 --- a/crates/ui-egui/src/panels/layout_panel.rs +++ b/crates/ui-egui/src/panels/layout_panel.rs @@ -23,9 +23,13 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> { doc.node(id).and_then(|n| n.parent).and_then(|p| doc.node(p)) } +/// Show selection sizing and layout controls; offer Add only when every selected container has +/// no auto layout and the selection is editable outside an instance. pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); + let layout_containers = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Group))); + let no_al = !sel.is_empty() && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.layout.is_none())); let al = common(doc, sel, |n| n.layout.clone()); let has_al = matches!(al, Some(Some(_))); let title = if has_al { tr("Auto layout") } else { tr("Layout") }; @@ -35,7 +39,7 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i)); let btns: Vec<(Icon, &str)> = if in_instance { vec![] - } else if frames && !has_al { + } else if layout_containers && no_al { vec![(Icon::Plus, crate::shortcuts::tip(tr("Add auto layout"), "layout.add"))] } else if has_al { vec![(Icon::Minus, crate::shortcuts::tip(tr("Remove auto layout"), "layout.remove"))] diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index a24fca55..095649ec 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -113,10 +113,10 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N } }); } - let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round()); + let sm = common(doc, sel, |n| n.corners.smoothing * 100.0); widgets::sublabels(ui, tr("Corner smoothing"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::unique("smoothing"), Lead::None, sm, "%", col, 1.0); + let e = widgets::number_field(ui, Id::unique("smoothing"), Lead::Icon(Icon::CornerSmoothing, tr("Corner smoothing")), sm, "%", col, 1.0); app.field(&ids, "cornerSmoothing", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); if widgets::button(ui, tr("iOS"), false, true).on_hover_text(tr("Set corner smoothing to 60%")).clicked() { app.set_props(&ids, json!({"cornerSmoothing": 0.6})); diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index ea2cc54f..5dfc3c8e 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -1,6 +1,6 @@ # Auto layout and constraints parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** minor (Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) Auto layout is the most used layout feature in Figma and the one where small differences (a hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%, @@ -9,6 +9,24 @@ positions it implies (`crates/layout/src/behaviour_tests.rs`, 64 cases, and `crates/engine/src/al_behaviour_tests.rs` through commands), and all pass; nothing yet against real Figma files. Remaining: 3–6 h. +## Adding auto layout to a group + +With one group selected, Add auto layout converts that group into a frame in place, retaining its +id, name, parent and direct children. The Layout sidebar’s +, context menu, Shift+A and API calls +all dispatch `layout.add`, so they share the conversion and a single undo step restores the group. +The layout settings are inferred from visible, non-absolute children, matching the layout engine’s +flow. Hidden and absolute children therefore do not distort direction, gap or padding. A converted +group keeps Fill sizing on either axis in its auto layout parent. Sorting by position preserves +mask boundaries and the paint order within masked runs. The sidebar offers Add only when every +selected container has no auto layout, including mixed selections. + +This follows the Figma behavior reported by the contributor and the sidebar workflow users know from +similar design tools: start with a group and make its children participate in auto layout. +Wrapping the group would instead make the entire group one layout item, leaving its children +outside the new frame’s layout flow and adding an unnecessary level to the layer tree. Converting +the selected container makes later changes to a child’s size reflow its siblings directly. +Selecting several groups still wraps that selection in a shared auto layout frame. + ## Rules from the documentation Status: **pass** (a case in the suite holds), **fixed** (the suite found a divergence, now fixed), @@ -69,7 +87,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` | | Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | | | Layout grids: columns, rows, grid; stretch/left/centre/right; variables bound | done | | -| Suggest auto layout / "Add auto layout" on a selection | done | adding auto layout to an empty frame keeps its size, Fixed (it collapsed to 20×20); a frame with content still hugs it, as does a new frame wrapped around a selection (#369). `node.set` with auto layout keys on a mix of layers applies them to the frames and warns about the rest instead of failing the whole call (#603) | +| Suggest auto layout / "Add auto layout" on a selection | done | the Layout sidebar exposes Add auto layout (+) for groups and frames using the same `layout.add` command as the context menu; a single group becomes a frame in place, keeping its id, name and direct children; adding auto layout to an empty frame keeps its size, Fixed (it collapsed to 20×20); a frame with content still hugs it, as does a new frame wrapped around a selection (#369). `node.set` with auto layout keys on a mix of layers applies them to the frames and warns about the rest instead of failing the whole call (#603) | | Real Figma files laid out identically | **not checked** | needs `.fig` or REST import of real files ([gaps](gaps.md) #1, #3) | | Performance: re-flow only where sizes or places changed | done | measured in `cargo xtask bench` (components at scale) | @@ -77,6 +95,8 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M2 review fixes: conversion preserves mask runs and Fill sizing per axis; layout inference uses visible, non-absolute flow children; the sidebar hides Add for mixed selections containing existing auto layout. Regression coverage includes rendered mask pixels, visible-child positions, parent layout stability and the sidebar accessibility tree. Numbers unchanged | +| 2026-10-29 | minor | M2: the Layout sidebar exposes Add auto layout (+) for groups. `layout.add` converts one selected group into a frame in place rather than wrapping it: same id, name and direct children; the layout arranges those children, and one undo restores the group. Sidebar, menu, shortcut and API calls all use the corrected command. This follows the contributor’s observed Figma behavior and the workflow familiar to users of similar design tools. Tests cover direct-child reflow, stable hierarchy, undo/redo and wrapping a multi-group selection. Numbers unchanged | | 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged | | 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | | 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 904b33b9..91ac86f0 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -28,6 +28,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address and block (`Action::Admin`), on every server, with the server key and with an owner token alike; listing and looking aren't counted. Before, a managing connection with the key had no limit at all and an owner token only the message rate, so one address could ask for hundreds of 10,000-round code stretches and `links.json` writes a second (100 renames in a row: 100 served before, 60 now, then "too many changes to this server's files from your address…"; another address is unaffected). A typed or new join code is found through an index of code hashes to the files holding them, instead of opening every link's code on the server; results are the same, a copied file folder holding the same code included. The protocol is unchanged. Tests: `admin_budget_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f44e2296..fc779ba0 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -30,7 +30,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft | Notes | Hours | |---|---|---|---|---| -| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on one path as text on a path (on several paths as outlines); fuzzed | 2–4 (filters) | +| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on one path as text on a path (on several paths as outlines); a pattern's tile is drawn once per scale however many layers use it, and its layers count in the import's 100,000-layer budget; fuzzed | 2–4 (filters) | | PNG, JPG | yes | yes | embedded ICC profiles (PNG `iCCP`, JPG APP2) and PNG `sRGB` / `gAMA` / `cHRM` chunks convert the image into the file's colour space; no profile (or CMYK, damaged): taken as sRGB | — | | GIF | yes (animated) | **yes**, animated | the file is kept as is; the canvas and PNG, JPG and PDF exports show the first frame (SVG exports embed the file, so viewers that animate GIFs animate it), the presenter and the preview play it (frames decoded on first play: at most 500 frames and 128 MB, past that the rest don't play; always loops) | — | | WebP | yes | yes | embedded ICC profile honoured | — | @@ -184,6 +184,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills (1,000 rectangles filled with one pattern: 1,000 tile drawings before, 1 now, same image fill), and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one, so patterns nested in patterns stop at the budget. Tests: `tile_tests` (svg). Numbers unchanged | | 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | | 2026-10-29 | minor | Fonts: the missing-font row adds downloading a missing family the font store has (Google Fonts, Noto) from the Missing fonts window, after the user allows it (#754). Replacing a missing font with another is still missing. Numbers unchanged | | 2026-10-29 | minor | Image key names (#1086): one rule now covers the archive reader, the collab entries and the REST importer's placeholders — 1 to 128 ASCII letters, digits, `-` or `_`. The reader accepts `-` and `_` from now on, so the REST importer's `rest-image-missing` placeholder survives a save and load instead of being dropped as damaged; a placeholder whose `imageRef` can't name an archive member (path text, an over-long ref) or is shaped like a content hash (64 hex digits, which the placeholder's bytes don't hash to) now shares that placeholder instead of being keyed by its raw name. The importers' largest image (256 MiB) is one constant, `figcraft_doc::ImageData::MAX_BYTES`, which live sessions use too. Numbers unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index cac8392f..2c04ec5b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (`.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -273,6 +273,13 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe sixteenth of the pixels first, then refined. With no measure to go by (the first render, another page, a zoom after quick renders), a watchdog thread previews any such render still running after 12 ms (desktop only; the browser renders inline). +- **Landed (2026-10-29):** the browser draws a zoom's full render a band at a time (`Retained::begin`, + `Steps`; at least a band a frame and at most eight bands, so a frame draws a part of the render + where it drew the whole of it: 0.7–1.4 s where the render took 3.7–4.0 s in the measurement of + #1496), so input is handled while it draws: a pan or an edit meanwhile is patched into + the render once drawn (or it is drawn again, the first shown till then), a zoom drops it. Bands + overlap by what background blurs read, as the desktop's parallel bands do, and the bands together + take longer than one render (each repeats its overlap and some per-band work). - **Landed (2026-10-28):** the browser refines a zoom's preview only once no newer request has come for 120 ms (`SETTLE_MS`), or for as long as its last full render took when that is shorter: its full renders run on the UI thread, and one between every two wheel steps held the page up @@ -555,6 +562,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Gap 7: the browser draws a zoom's full render a band at a time (eight at most), so a deep zoom no longer holds the page for the whole render; pans and edits meanwhile are patched in once it is drawn. Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 2026-10-28 | minor | Gap 7: the browser refines a zoom's preview only once the zoom has paused for 120 ms, so a wheel zoom no longer runs a blocking full render between every two steps. Numbers unchanged | | 2026-10-27 | minor | M12: the MCP agent feedback workflow landed (comments as tasks, `comment.prompt`, Copy for AI, `fix_comments` / `watch_comments`, `figcraft://comments`, a bounded event log, `events.poll` and `wait_for_changes`). Figma AI features and local models are still not started, so gap 19's missing list and estimate stand | diff --git a/docs/roadmap.md b/docs/roadmap.md index 5e3a727d..195a6a59 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -76,6 +76,8 @@ blockers. | Date | Change | Summary | |---|---|---| | 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | +| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files a budget of 60 a minute per address, with the server key and owner tokens alike, and finds join codes through an index of their hashes. Tests: `admin_budget_tests` | +| 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills, and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one. Tests: `tile_tests` (svg) | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | M8, #1061: a mask group's pixmaps cover only where its mask or content can paint within the view (a 40 × 40 mask on a 3000 × 2000 view: 96 MB → 61.5 kB, same pixels), and isolated layers share a 2 GiB budget held at once per render thread; a layer past it is left out with a warning. Tests: `mask_bounds_tests` | diff --git a/docs/server.md b/docs/server.md index a88d3a3c..0dcd26c9 100644 --- a/docs/server.md +++ b/docs/server.md @@ -498,7 +498,7 @@ are larger. | Bytes being read | Messages not yet whole: the largest message and 1 MiB per block, 192 MiB for everyone; past everyone's budget the readers holding the most are cut off until it fits (their bytes count as free at once), and a reader is refused only when it is the biggest | | Bytes waiting for the server | 128 MiB of read messages not handled yet: past it a connection is dropped as busy | | Connections | `max_connections` (512) in all (*always*); `max_connections_per_ip` (32) per address, ×2 per IPv6 /56 (at most a sixteenth of all), ×4 per /48 (at most an eighth of all), joins included, so no block can take every connection; one-shot requests (asking about hosting, libraries, browsing plugins) can't take the last eighth, which stays for people joining files, and are dropped after 20 idle seconds | -| Rates | Per address and block: `connects_per_minute`, `publishes_per_hour` (and 32 addresses' worth for everyone), `messages_per_second` (cursors, selections, cursor chat and pings have their own budget beside it, twice that, so an office moving its cursors together doesn't trip it); reports, ten an hour (*always*); downloads of libraries and plugins, 1 MiB a second with bursts of 16 MiB per address (*always*), checked before anything is read, within 32 MiB a second (512 MiB at once) for libraries and for plugins, everyone together | +| Rates | Per address and block: `connects_per_minute`, `publishes_per_hour` (and 32 addresses' worth for everyone), `messages_per_second` (cursors, selections, cursor chat and pings have their own budget beside it, twice that, so an office moving its cursors together doesn't trip it); reports, ten an hour (*always*); requests that change files (join codes, links, renames, removals, blocks, publishing with the key), sixty a minute, with the server key or an owner token too ("too many changes to this server's files from your address…"; listing and looking aren't counted) (*always*); downloads of libraries and plugins, 1 MiB a second with bursts of 16 MiB per address (*always*), checked before anything is read, within 32 MiB a second (512 MiB at once) for libraries and for plugins, everyone together | | Team libraries | Only files published with the key hold one on a public server; an archive is at most `max_file_mb` and everything it unpacks to counts against the same cap (*always*), so a zip bomb is refused before it expands | | Usage reports | Kept in memory, written at most once a minute per library and when the server stops; at most 1,000 components per file and 200 files per library (*always*) | | Disk writes | A batch is confirmed only once it is on disk; when its log can't be written, nobody hears it was accepted: the file closes, everyone in it reconnects and their apps send again what wasn't confirmed. Below 1 GiB (or two of the largest files) free, changes and new files are refused with "the server's disk is nearly full" instead (*always*, where the free space can be told: Unix). The server keeps the last 200 problems for the operator | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1503b8e5..e0a07b1d 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-29 · **Change:** minor (Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -64,11 +64,11 @@ interaction feel not yet compared. | 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 | +| 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`; a guide dragged out of a ruler or moved snaps to layer edges and centres too (Snap to Objects; Cmd/Ctrl suspends), on its own axis only; page guides see the page's top-level layers, frame guides the frame's box, layout grids and every layer in it, along a rotated frame's own axes; thresholds not yet compared with Figma's (`guide_snap_tests`) | | 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; the arrows still nudge while measuring (Shift: the big step) and the distances follow; the selection's handles and size label hide while measuring (`measure_nudge_tests`, #894) | | Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) | -| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | +| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Corner smoothing has an original code-drawn icon, a 24 pt drag target and an accessible name, with live changes, fractional values preserved across consecutive drags, Shift/Alt rates, 0–100% clamping and one-step undo/redo (`corner_individual_tests`); Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) | @@ -174,6 +174,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M1: Corner smoothing has an original icon and a 24 pt drag target, using the shared field gesture (live changes, fractional values preserved across consecutive drags, Shift/Alt rates, 0–100% clamping, one undo step). The field now has an accessible name (`corner_individual_tests`). Numbers unchanged | | 2026-10-29 | minor | Crop image (#1464): Object ▸ Crop image, the right-click menu and a crop button on the image fill row enter crop mode on the canvas (`image.crop`; the image dimmed outside the layer, corner brackets and edge bars on its box; Enter, Escape or a click outside finishes). The row stays done | | 2026-10-29 | minor | Toolbar (#1210): every state of the pill (tools, vector edit, shape builder, view only) is laid out on one 32 pt cell, 8 pt in from its ends and 8 pt between its items, with a 1 pt divider and 14 pt mode-switcher marks; the Resources button is gone, as in UI3 (the left rail's Assets tab, Shift+I, Alt+2 and View ▸ Panels ▸ Assets open the Assets panel); a narrow window still scrolls the pill (#1209) (`resources_tests`, `toolbar` tests). Numbers unchanged | | 2026-10-27 | minor | The Design panel's sections sit on one grid (#858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows, 11 pt semibold section titles over 10 pt captions, 4 pt between the two buttons at a row's end; the panel's frame no longer carries a stroke (it counts as margin), so an icon's box ends 8 pt from the window edge. Checked in the app on Windows over the control channel | @@ -187,6 +188,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | | 2026-10-18 | minor | Export: an "XML" format (Android Vector Drawable, said on hover) in the Export section's format menu, with "Include id attribute" and a compatibility choice (best effort, exact only, ignore unsupported) in its settings, and an XML preset in File ▸ Export…; the export toast says when content was changed or left out. Numbers unchanged | +| 2026-10-29 | minor | Guides snap to layers when dragged: a guide pulled from a ruler or moved snaps to nearby layer edges and centres (Snap to Objects; Cmd/Ctrl suspends), on its own axis only, within five screen pixels at any zoom; page guides see the page's top-level layers, frame guides the frame's box, layout grids and every layer in it, along a rotated frame's own axes; a snapped guide keeps the edge exactly while an unsnapped one still rounds to the pixel (`guide_snap_tests`). Summary and hours unchanged | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME |