diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index feb66132..dcd0192f 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -43,8 +43,9 @@ jobs: mem: 12288 # Nothing to bring back; copying the target dir out of the VM fails on large builds. copyback: false + # dejavu: the text tests need one real font, as the Ubuntu runner has. prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 + pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 dejavu curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal run: | set -e diff --git a/Cargo.lock b/Cargo.lock index 467e7a84..aead39d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1681,6 +1681,7 @@ dependencies = [ "figcraft-geom", "harfrust 0.13.3", "log", + "png", "skrifa 0.47.0", "unicode-bidi", "unicode-segmentation", @@ -1708,6 +1709,7 @@ dependencies = [ "serde_json", "toml", "wat", + "web-time", ] [[package]] @@ -1719,6 +1721,7 @@ dependencies = [ "figcraft-collab-net", "figcraft-engine", "figcraft-ui-egui", + "js-sys", "log", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/README.md b/README.md index 9b67078b..0910bf9e 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English and Spanish (a draft waiting for a native speaker's review), picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). diff --git a/ROADMAP.md b/ROADMAP.md index 85d1a36f..76785efd 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-15 · **Change:** minor (Selection colors for mixed selections (#403); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Selection colors for mixed selections (#403); Typography spacing icons support scrubbing (#373); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 100% | draft (needs native review) | | Vietnamese | vi | 0% | none | -No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI @@ -314,6 +314,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 2026-10-15 | minor | Selection colors (#403): inspect and edit solid/gradient fills and strokes across descendants and text ranges, apply document color styles, select matching layers; one Undo step per picker drag. See docs/ui-parity.md for the bounded style behavior and remaining parity; headline estimates unchanged. | +| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. | | 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 9b0f9b55..4fb485e1 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -38,7 +38,7 @@ fn write(path: &str, bytes: &[u8]) -> CliResult<()> { fn page_named(doc: &Document, name: Option<&str>) -> CliResult { match name { - None => doc.first_page().ok_or_else(|| CliError::Failed("the document has no pages".into())), + None => doc.first_open_page().or_else(|| doc.first_page()).ok_or_else(|| CliError::Failed("the document has no pages".into())), Some(n) => doc .pages .iter() @@ -380,6 +380,22 @@ mod tests { assert_eq!(sanitize(""), "layer"); } + #[test] + fn default_page_skips_leading_divider() { + let mut doc = Document::new("divider"); + let page = doc.first_page().unwrap(); + doc.create(page, figcraft_doc::NodeKind::Frame, "First", 0.0, 0.0, 40.0, 30.0).unwrap(); + let divider = doc.add_page("- Divider"); + doc.pages.retain(|p| *p != divider); + doc.pages.insert(0, divider); + assert!(doc.is_divider(divider)); + assert_eq!(page_named(&doc, None).unwrap(), page); + assert_eq!(page_named(&doc, Some("- Divider")).unwrap(), divider); + let pm = render_page(&doc, page_named(&doc, None).unwrap(), 1.0).unwrap(); + assert_eq!((pm.width(), pm.height()), (40, 30)); + assert!(render_page(&doc, divider, 1.0).is_err()); + } + #[test] fn export_depth_errors_preserve_conversion_destinations() { let mut doc = Document::new("export depth"); diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs index c08ddc04..03190306 100644 --- a/apps/figcraft-cli/src/perf.rs +++ b/apps/figcraft-cli/src/perf.rs @@ -438,44 +438,66 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes mean(3, |i| exec(&mut s, "node.set", json!({"ids": labels, "props": {"fontSize": 13 + (i % 2) * 2}}))), ); let doc = s.doc().map_err(failed)?.clone(); - let (card, _) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]).ok_or_else(|| failed("no card"))?; - table.put( - col, - "switch a card's variant (nested overrides kept)", - mean(5, |i| { - exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}})) - }), - ); - let (nav, _) = instance_with(&doc, &inst, ds.nav, &["Profile"]).ok_or_else(|| failed("no nav"))?; - table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true)); - table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true)); - let (button, bglyph) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]).ok_or_else(|| failed("no button"))?; - table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true)); - // A button glyph the drop really lands on: screens can overlap (text sizes differ without - // fonts), so ask the hit test the swap uses. - let now = s.doc().map_err(failed)?.clone(); - let page = s.page().map_err(failed)?; - let b = std::iter::once(bglyph) - .chain(inst.iter().filter_map(|i| { - let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None }; - (figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))? - })) - .filter_map(|g| now.world_bounds(g)) - .find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some()) - .ok_or_else(|| failed("no button glyph to drop on"))?; - let other = ds.icons.get(5).copied().unwrap_or(icon0); - table.put( - col, - "Alt-drop swap onto an instance", - timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true), - ); - exec(&mut s, "edit.copy", json!({"ids": [nav]})); - table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true)); + // Small workloads may not contain every instance type; the rows needing a missing one stay empty. + if let Some((card, _)) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]) { + table.put( + col, + "switch a card's variant (nested overrides kept)", + mean(5, |i| { + exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}})) + }), + ); + } else { + table.put(col, "switch a card's variant (nested overrides kept)", f64::NAN); + } + let nav = instance_with(&doc, &inst, ds.nav, &["Profile"]).map(|(nav, _)| nav); + if let Some(nav) = nav { + table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true)); + table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true)); + } else { + table.put(col, "reset all changes on a nav", f64::NAN); + table.put(col, "detach a nav", f64::NAN); + } + if let Some((button, bglyph)) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]) { + table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true)); + // A button glyph the drop really lands on: screens can overlap (text sizes differ without + // fonts), so ask the hit test the swap uses. + let now = s.doc().map_err(failed)?.clone(); + let page = s.page().map_err(failed)?; + let drop_at = std::iter::once(bglyph) + .chain(inst.iter().filter_map(|i| { + let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None }; + (figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))? + })) + .filter_map(|g| now.world_bounds(g)) + .find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some()); + let other = ds.icons.get(5).copied().unwrap_or(icon0); + table.put( + col, + "Alt-drop swap onto an instance", + match drop_at { + Some(b) => timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true), + None => f64::NAN, + }, + ); + } else { + table.put(col, "push a button's changes to its main", f64::NAN); + table.put(col, "Alt-drop swap onto an instance", f64::NAN); + } + if let Some(nav) = nav { + exec(&mut s, "edit.copy", json!({"ids": [nav]})); + table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true)); + } else { + table.put(col, "copy + paste a nav", f64::NAN); + } table.put(col, "undo (deep main edit)", mean(10, |_| _ = s.undo())); table.put(col, "redo", mean(10, |_| _ = s.redo())); // As a library: publish it, use it from another file, publish a change, accept the update. - let dir = std::env::temp_dir().join(format!("figcraft-perf-{}", std::process::id())); + // One directory per run, so concurrent runs in a process don't delete each other's files. + static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id())); std::fs::create_dir_all(&dir).map_err(failed)?; let path = dir.join("design-system.figcraft").to_string_lossy().into_owned(); let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?; @@ -533,6 +555,14 @@ mod tests { assert!(t.rows.iter().all(|r| r.1.len() == 1 && r.1[0].is_finite()), "{:?}", t.rows); } + #[test] + fn measures_components_with_a_single_instance() { + let mut t = super::Table { rows: Vec::new() }; + super::measure_components(&mut t, 0, 1).unwrap(); + assert!(t.rows.iter().all(|r| r.1.len() == 1), "{:?}", t.rows); + assert!(t.rows.iter().any(|r| r.0.starts_with("build design system") && r.1[0].is_finite()), "{:?}", t.rows); + } + #[test] fn measures_a_small_document() { let mut t = super::Table { rows: Vec::new() }; diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 8dfbb708..538adeff 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -347,14 +347,14 @@ impl Store { let key = load_key(root)?; let dir = root.join(FILES); fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; - let (directory, problems) = Directory::open(root, cfg.directory); + let (directory, startup_problems) = Directory::open(root, cfg.directory); let abuse = Abuse::new(cfg.hosting.abuse); let mut store = Store { root: root.to_path_buf(), cfg, key, files: BTreeMap::new(), - problems, + problems: Vec::new(), directory, abuse, served: figcraft_collab_net::net::Bucket::new(LIBRARY_RATE, LIBRARY_BURST), @@ -368,6 +368,9 @@ impl Store { free: None, _lock: lock, }; + for problem in startup_problems { + store.note(problem); + } store.scan(&dir); store.blocked = store.read_list::(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect(); store.reports = store.read_list(REPORTS); @@ -385,7 +388,7 @@ impl Store { match disk::read_capped(&path, 16 << 20).and_then(|b| serde_json::from_slice(&b).map_err(|e| format!("{name} is damaged ({e})"))) { Ok(list) => list, Err(e) => { - self.problems.push(e); + self.note(e); Vec::new() } } @@ -394,7 +397,7 @@ impl Store { fn scan(&mut self, dir: &Path) { let entries = match fs::read_dir(dir) { Ok(e) => e, - Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())), + Err(e) => return self.note(format!("can't list {}: {e}", dir.display())), }; let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect(); ids.sort(); @@ -403,18 +406,18 @@ impl Store { continue; } if !valid_id(&id) { - self.problems.push(format!("skipped {}: not a file folder of this server", path.display())); + self.note(format!("skipped {}: not a file folder of this server", path.display())); continue; } if self.files.len() >= self.cfg.max_files { - self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files)); + self.note(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files)); continue; } match self.check(&path) { Ok(entry) => { self.files.insert(id, entry); } - Err(why) => self.problems.push(format!("skipped file {id}: {why}")), + Err(why) => self.note(format!("skipped file {id}: {why}")), } } } @@ -1489,6 +1492,8 @@ impl Backend for Store { Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), Admin::Owned { files } => self.owned(&files).into(), + // The hub removes people itself (it knows who is connected); it never asks the store. + Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), Admin::Remove { file } => { if !self.files.contains_key(&file) { return failed("this file isn't on the server (it may have been removed)".into()); diff --git a/apps/figcraft-server/tests/problem_retention.rs b/apps/figcraft-server/tests/problem_retention.rs new file mode 100644 index 00000000..221eaa6e --- /dev/null +++ b/apps/figcraft-server/tests/problem_retention.rs @@ -0,0 +1,40 @@ +use figcraft_server::config::Config; +use figcraft_server::store::{FILES, Store}; + +#[test] +fn startup_problem_retention() { + let root = std::env::temp_dir().join(format!("figcraft-problem-cap-{}", std::process::id())); + assert!(!root.exists()); + let result = (|| -> Result<(), String> { + for count in [3u32, 200, 201] { + let data = root.join(count.to_string()); + for i in 0..count { + std::fs::create_dir_all(data.join(FILES).join(format!("{i:016x}"))).map_err(|e| e.to_string())?; + } + let cfg = Config { open_publish: true, data: data.clone(), ..Config::default() }; + cfg.check()?; + let store = Store::open(&data, cfg.store())?; + let expected = (count as usize).min(200); + let problems = store.problems().to_vec(); + let no_loaded_files = store.is_empty(); + drop(store); + if !problems.iter().all(|p| p.contains("meta.json")) || !no_loaded_files { + return Err("unexpected diagnostic or loaded file".into()); + } + if problems.len() != expected { + return Err(format!("expected {expected} retained problems, got {}", problems.len())); + } + // The latest problems stay, in scan order: the first folder's is the one dropped. + let first_kept = count as usize - expected; + for (n, p) in problems.iter().enumerate() { + let id = format!("{:016x}", first_kept + n); + if !p.contains(&id) { + return Err(format!("problem {n} should be about {id}: {p}")); + } + } + } + Ok(()) + })(); + std::fs::remove_dir_all(&root).unwrap(); + assert!(result.is_ok(), "{}", result.unwrap_err()); +} diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 93f9eb41..2f1a8190 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -24,5 +24,6 @@ log = { workspace = true } # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" +js-sys = "0.3" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 5bb20905..8cadb013 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -69,9 +69,9 @@ impl eframe::App for WebShell { } } -/// Browser services. File dialogs return nothing for now (the trait's defaults). Copies go to the -/// system clipboard through egui's output; pastes arrive as paste events with their text (a page -/// can't read the clipboard when it likes). +/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved +/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste +/// events with their text (a page can't read the clipboard when it likes). struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option, @@ -79,6 +79,19 @@ struct WebServices { } impl Services for WebServices { + fn downloads(&self) -> bool { + true + } + /// A Blob behind a temporary `` link, clicked and removed. + fn download(&mut self, name: &str, bytes: &[u8]) -> bool { + match download(name, bytes) { + Ok(()) => true, + Err(e) => { + log::error!("couldn't download {name}: {e:?}"); + false + } + } + } fn clipboard_set_text(&mut self, s: &str) { figcraft_ui_egui::egui_copy_text(&self.ctx, s); } @@ -116,3 +129,32 @@ impl Services for WebServices { held } } + +fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let body = document.body().ok_or("no body")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{ + let o = web_sys::BlobPropertyBag::new(); + o.set_type("application/octet-stream"); + o + })?; + let url = web_sys::Url::create_object_url_with_blob(&blob)?; + let a: web_sys::HtmlAnchorElement = document.create_element("a")?.dyn_into()?; + a.set_href(&url); + a.set_download(name); + body.append_child(&a)?; + a.click(); + a.remove(); + // Revoked later, not at once: some browsers cancel a download whose URL goes before it starts. + let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); + // Without the timer the URL just stays until the page closes. + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_DOWNLOAD_MS); + Ok(()) +} + +/// How long a download's object URL is kept (milliseconds). +const REVOKE_DOWNLOAD_MS: i32 = 40_000; diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index 5cfe1292..1c91840f 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -107,11 +107,17 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s }, Err(_) => { cancelled.store(true, std::sync::atomic::Ordering::Relaxed); - json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"}) + json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"}) } } } - Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}), + Err(e) => { + // A line that isn't JSON ends the connection: a web page can make the browser send + // an HTTP request here, and its header lines must not let the JSON lines of its body + // through as commands. + writeln!(out, "{}", json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}))?; + break; + } }; writeln!(out, "{reply}")?; } diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 2e0a7b48..7d174ef3 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -53,9 +53,8 @@ fn valid_frames_keep_their_order_ids_and_parameters() { let pending = queue(); let q = pending.clone(); let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))); - client.write_all(b"\r\n{bad JSON}\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap(); + client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap(); let mut reader = BufReader::new(client); - assert_eq!(reply(&mut reader)["ok"], false); let start = Instant::now(); let r = loop { if let Some(r) = pending.lock().unwrap().pop() { @@ -73,6 +72,23 @@ fn valid_frames_keep_their_order_ids_and_parameters() { worker.join().unwrap().unwrap(); } +#[test] +fn a_line_that_is_not_json_ends_the_connection_before_anything_after_it_runs() { + // What a browser sends when a web page posts to the port: the JSON line in its body never runs. + let (mut client, server) = pair(); + let pending = queue(); + let q = pending.clone(); + let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))); + let body = "{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.save\"}}\n"; + let http = format!("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len()); + client.write_all(http.as_bytes()).unwrap(); + let mut reader = BufReader::new(client); + assert_eq!(reply(&mut reader)["ok"], false); + assert_eq!(reader.read(&mut [0]).unwrap(), 0, "the connection is closed"); + worker.join().unwrap().unwrap(); + assert!(pending.lock().unwrap().is_empty(), "nothing was dispatched"); +} + #[test] fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() { let (mut client, server) = pair(); @@ -144,7 +160,8 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() { assert_eq!(workers.len(), 1); client.write_all(b"invalid\n").unwrap(); assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false); - client.shutdown(Shutdown::Both).unwrap(); + // Invalid JSON closes the connection; shutdown can return NotConnected on macOS. + assert_eq!(client.read(&mut [0]).unwrap(), 0); workers.pop().unwrap().join().unwrap(); } diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 7b14d8e2..fdcc66f9 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -17,6 +17,7 @@ mod gamepad; mod graphics; mod recovery; mod services; +mod startup; #[cfg(all(test, target_os = "macos"))] mod apple_events_tests; @@ -30,6 +31,8 @@ mod recovery_online_tests; mod recovery_tests; #[cfg(test)] mod services_tests; +#[cfg(test)] +mod startup_tests; use std::sync::{Arc, Mutex}; @@ -62,6 +65,7 @@ impl eframe::App for App { } let title = with_app(|fig| { control_server::drain_queue(&self.queue, &mut fig.control_queue); + figcraft_ui_egui::control::drain_hidden(fig, ctx); #[cfg(target_os = "macos")] apple_events::poll(&self.apple_events, fig, ctx); // Closing the window asks about unsaved changes (and keeps the app running while other @@ -90,9 +94,9 @@ impl eframe::App for App { } } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); - with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui))); + services::parented(frame, || with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui)))); } fn on_exit(&mut self) { @@ -166,6 +170,9 @@ fn main() -> eframe::Result { if session.docs.is_empty() && !first_run { let _ = session.execute("file.new", &json!({})); } + // The interface language from the first frame on, and for the message when the window can't + // open (no frame then). + figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref()); let mut fig = FigApp::new(session, Box::new(svc)); fig.ui.home = false; fig.note_recovered(recovered.copies); @@ -177,8 +184,11 @@ fn main() -> eframe::Result { control_server::start(p, queue.clone(), ctx_slot.clone()); } let icon = services::window_icon(); + // The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name + // and StartupWMClass (FreeBSD's package matches the binary's name instead). let mut viewport = egui::ViewportBuilder::default() .with_title("FigCraft") + .with_app_id("ai.storyteller.figcraft") .with_inner_size([1440.0, 900.0]) .with_min_inner_size([800.0, 500.0]) .with_drag_and_drop(true); @@ -203,7 +213,7 @@ fn main() -> eframe::Result { let apple_events = apple_events::AppleEvents::install(); #[cfg(target_os = "macos")] let apple_events = &apple_events; - eframe::run_native( + let result = eframe::run_native( "FigCraft", options, Box::new(move |_cc| { @@ -216,5 +226,10 @@ fn main() -> eframe::Result { apple_events: apple_events.connect(&_cc.egui_ctx), })) }), - ) + ); + // Say why instead of quitting silently: release builds on Windows have no console. + if let Err(e) = &result { + startup::failed(e, port.is_some()); + } + result } diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 0885c68e..b2614598 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -4,7 +4,10 @@ //! in the config folder, in the background, at most every [`INTERVAL`] seconds, and the copy goes //! once the document is saved or closed. The app holds a lock on its folder. At startup, the folders //! whose lock is free belong to runs that ended without closing: their copies open again as -//! unsaved documents (`file.recover`) and the folder goes. Closing the app normally removes its own. +//! unsaved documents (`file.recover`). Each such copy **stays until this run has its own copy of +//! the document** (or it is saved or closed): until then it is the only copy on disk, and a run +//! that can't keep copies of its own leaves it for the next start. Then it goes, and its folder +//! with its last copy. Closing the app normally removes its own. //! //! A document that is one of your online files has a note beside its copy (`.online`, written //! after the copy it describes): the server, the file's id, and who the app was in the file with @@ -22,7 +25,8 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use std::thread::JoinHandle; -use figcraft_engine::Session; +use figcraft_engine::{DocState, Session}; +use figcraft_format::{Branch, Version}; use serde_json::json; /// Seconds between copies of a document that keeps changing. @@ -52,35 +56,50 @@ pub struct Recovery { /// The state of the copy of one document: only a write that succeeded counts as a copy. #[derive(Default)] struct Copy { - /// The revision whose copy was written. - done: Option, - /// The write in flight: the revision it copies, and whether it succeeded once it ends. - flight: Option<(u64, JoinHandle)>, + /// The [`stamp`] whose copy was written. + done: Option>, + /// The write in flight: the stamp it copies, and whether it succeeded once it ends. + flight: Option<(Vec, JoinHandle)>, } impl Copy { /// Take in the result of the write in flight once it ended; a failed one leaves its revision uncopied. fn reap(&mut self) { - if self.flight.as_ref().is_some_and(|f| f.1.is_finished()) - && let Some((revision, h)) = self.flight.take() - && h.join().unwrap_or(false) - { - self.done = Some(revision); + if self.flight.as_ref().is_some_and(|f| f.1.is_finished()) { + self.join(); } } - /// Whether `revision` has neither been copied nor is being copied. - fn pending(&self, revision: u64) -> bool { - self.done != Some(revision) && self.flight.as_ref().is_none_or(|f| f.0 != revision) + /// Wait for the write in flight and take in its result. + fn join(&mut self) { + if let Some((stamp, h)) = self.flight.take() + && h.join().unwrap_or(false) + { + self.done = Some(stamp); + } + } + + /// Whether `stamp` has neither been copied nor is being copied. + fn pending(&self, stamp: &[u64]) -> bool { + self.done.as_deref() != Some(stamp) && self.flight.as_ref().is_none_or(|f| f.0 != stamp) } } -/// A copy of one of your online files whose file is being joined again as session `session`. +/// A copy from an earlier run, kept until what it became is safe. pub struct Waiting { - session: u64, + until: Until, copy: PathBuf, - /// The lock of the folder it is in: no other start takes the copy meanwhile. - _lock: Arc, + /// The lock of the folder it is in: no other start takes the copy meanwhile (none for a copy + /// left directly in the root by older versions). + _lock: Option>, +} + +/// What a kept copy waits for. +enum Until { + /// One of your online files, joined again as this session: its outcome. + Session(u64), + /// Opened as document `uid`: this run's own copy of it (or its save or close). + Copied(u64), } /// What [`recover_all`] brought back. @@ -88,21 +107,26 @@ pub struct Waiting { pub struct Recovered { /// Documents opened from their copies. pub copies: usize, - /// Copies of online files waiting for their file ([`Recovery::adopt`]). + /// The copies kept until what they became is safe ([`Recovery::adopt`]). pub waiting: Vec, } impl Recovery { /// Take this run's folder under `root` (`None` when it can't be created or locked: no copies). + /// A crashed run with the same process id may still hold its folder (its copies kept): then + /// another name is taken. pub fn start(root: &Path) -> Option { - let dir = root.join(std::process::id().to_string()); - std::fs::create_dir_all(&dir).ok()?; - let lock = File::create(dir.join(LOCK)).ok()?; - lock.try_lock().ok()?; - Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() }) + let pid = std::process::id(); + (0..10).find_map(|n| { + let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") }); + std::fs::create_dir_all(&dir).ok()?; + let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?; + lock.try_lock().ok()?; + Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() }) + }) } - /// Keep earlier runs' copies of online files until their outcome is known. + /// Keep earlier runs' copies until what they became is safe. pub fn adopt(&mut self, waiting: Vec) { self.waiting.extend(waiting); } @@ -111,10 +135,12 @@ impl Recovery { /// were saved or closed. Returns how long until it needs to run again (`None`: nothing pending, /// an idle window can sleep). pub fn tick(&mut self, s: &Session, now: f64) -> Option { - self.settle(s); self.written.values_mut().for_each(Copy::reap); - // A join on its way settles within its timeouts: look again soon. - let settling = (!self.waiting.is_empty()).then_some(0.5); + self.settle(s); + // A join on its way settles within its timeouts: look again soon. A copy waiting for this + // run's own is looked at again with the next copies. + let joining = self.waiting.iter().any(|w| matches!(w.until, Until::Session(_))); + let settling = if joining { Some(0.5) } else { (!self.waiting.is_empty()).then_some(INTERVAL) }; if now < self.next { return self.pending(s).then_some(self.next - now).or(settling); } @@ -126,29 +152,30 @@ impl Recovery { /// Whether a document with unsaved changes has a revision that is neither copied nor being copied. fn pending(&self, s: &Session) -> bool { - s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.pending(d.revision))) + s.docs.iter().any(|d| needs_copy(s, d) && self.written.get(&d.uid).is_none_or(|w| w.pending(&stamp(s, d)))) } - /// Remove earlier runs' copies whose outcome is known, once what they became is copied here. - fn settle(&mut self, s: &Session) { - if self.waiting.iter().all(|w| s.live.recovering(w.session)) { - return; + /// Remove earlier runs' copies once what they became is safe: copied here (the write + /// succeeded), saved or closed. An online file's copy goes once its outcome is known and every + /// document is safe, since which of them it became isn't known. + pub fn settle(&mut self, s: &Session) { + let joined = |w: &Waiting| matches!(w.until, Until::Session(id) if !s.live.recovering(id)); + if self.waiting.iter().any(joined) { + self.write(s, true); } - self.write(s, true); - let (done, waiting): (Vec, Vec) = - std::mem::take(&mut self.waiting).into_iter().partition(|w| !s.live.recovering(w.session)); + let all_safe = s.docs.iter().all(|d| self.safe(s, d)); + let (done, waiting): (Vec, Vec) = std::mem::take(&mut self.waiting).into_iter().partition(|w| match w.until { + Until::Session(_) => joined(w) && all_safe, + Until::Copied(uid) => s.docs.iter().find(|d| d.uid == uid).is_none_or(|d| self.safe(s, d)), + }); self.waiting = waiting; - for w in done { - let _ = std::fs::remove_file(&w.copy); - let _ = std::fs::remove_file(w.copy.with_extension(ONLINE)); - // The folder goes with its last copy (while another waits there its lock is held). - let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| Arc::strong_count(&w._lock) == 1); - drop(w); - if let Some(dir) = dir { - let _ = std::fs::remove_file(dir.join(LOCK)); - let _ = std::fs::remove_dir(dir); - } - } + done.into_iter().for_each(remove_kept); + } + + /// Whether document `d`'s work is on disk: saved (or nothing to keep), or copied here (a write + /// that succeeded) with no write in flight. + fn safe(&self, s: &Session, d: &DocState) -> bool { + !needs_copy(s, d) || self.written.get(&d.uid).is_some_and(|w| w.done.is_some() && w.flight.is_none()) } /// Copy every document with unsaved changes; `wait`: before returning (after a panic). @@ -156,27 +183,36 @@ impl Recovery { for d in &s.docs { let path = self.dir.join(format!("{}.figcraft", d.uid)); let note = path.with_extension(ONLINE); - if !needs_copy(d) { - if self.written.remove(&d.uid).is_some() { + if !needs_copy(s, d) { + if let Some(mut w) = self.written.remove(&d.uid) { + // A write still running would bring the copy back after it was removed. + w.join(); let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(¬e); } continue; } let entry = self.written.entry(d.uid).or_default(); - entry.reap(); + // An emergency save must copy the newest revision: let the write in flight finish first. + if wait { + entry.join(); + } else { + entry.reap(); + } // A write still in flight (for any revision) is waited for; a failed one is tried again. - if entry.done == Some(d.revision) || entry.flight.is_some() { + let stamp = stamp(s, d); + if entry.done.as_ref() == Some(&stamp) || entry.flight.is_some() { continue; } let doc = d.doc.clone(); + let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid)); // Which online file it is and what of the server's this very copy has (a line each: // the server, the file's id, then the session, client id and last change), while it // is one. Written after the copy, never before: a note must not promise more of the // server's changes than its copy holds. let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq)); let job = move || { - if !write_atomic(&path, &doc) { + if !write_atomic(&path, &doc, &versions, &branches) { return false; } let _ = match online { @@ -185,21 +221,22 @@ impl Recovery { }; true }; - // The revision counts as copied only once its write succeeded. + // The stamp counts as copied only once its write succeeded. if wait { if job() { - entry.done = Some(d.revision); + entry.done = Some(stamp); } } else { - entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (d.revision, h)); + entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (stamp, h)); } } // Closed documents. let open: Vec = s.docs.iter().map(|d| d.uid).collect(); let dir = &self.dir; - self.written.retain(|uid, _| { + self.written.retain(|uid, w| { let keep = open.contains(uid); if !keep { + w.join(); let _ = std::fs::remove_file(dir.join(format!("{uid}.figcraft"))); let _ = std::fs::remove_file(dir.join(format!("{uid}.{ONLINE}"))); } @@ -207,10 +244,18 @@ impl Recovery { }); } + /// Test hook: pretend a write of `uid` at `revision` is in flight in `writer`. + #[cfg(test)] + pub fn hold(&mut self, uid: u64, revision: u64, writer: JoinHandle) { + self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) }); + } + /// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier /// runs' copies still waiting for their outcome stay for the next start.) pub fn finish(self) { - let Self { dir, _lock, written, .. } = self; + let Self { dir, _lock, written, waiting, .. } = self; + // The documents earlier runs' copies became were saved or discarded too. + waiting.into_iter().filter(|w| matches!(w.until, Until::Copied(_))).for_each(remove_kept); // Windows removes a folder only once nothing in it is open: copies being written first. for (_, h) in written.into_values().filter_map(|w| w.flight) { let _ = h.join(); @@ -220,6 +265,20 @@ impl Recovery { } } +/// Remove a kept copy and its note, and its folder with its last copy (while another is kept +/// there, its lock is held). +fn remove_kept(w: Waiting) { + let _ = std::fs::remove_file(&w.copy); + let _ = std::fs::remove_file(w.copy.with_extension(ONLINE)); + let last = w._lock.as_ref().is_some_and(|l| Arc::strong_count(l) == 1); + let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| last); + drop(w); + if let Some(dir) = dir { + let _ = std::fs::remove_file(dir.join(LOCK)); + let _ = std::fs::remove_dir(dir); + } +} + /// The extension of the note beside the copy of a document that is one of your online files. const ONLINE: &str = "online"; @@ -236,15 +295,27 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option { s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64() } -/// A document whose changes would be lost (a branch tab's edits are in its main file). -fn needs_copy(d: &figcraft_engine::DocState) -> bool { - d.is_dirty() && d.branch_of.is_none() +/// A file whose changes would be lost: in its document, its history or one of its branch tabs +/// (a branch tab's edits are saved with its main file, so the tab has no copy of its own). +fn needs_copy(s: &Session, d: &DocState) -> bool { + d.branch_of.is_none() && (d.is_dirty() || branch_tabs(s, d.uid).any(DocState::is_dirty)) } -/// Write `doc` to `path` through a temporary file; false when it couldn't be written (the copy -/// before it is still there). -fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) -> bool { - let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return false }; +/// The tabs showing branches of the file in tab `uid`. +fn branch_tabs(s: &Session, uid: u64) -> impl Iterator { + s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid)) +} + +/// The revisions a copy of `d` is of: its own and its branch tabs' (a branch tab's edits change +/// only its own revision). A new copy is due when they change. +fn stamp(s: &Session, d: &DocState) -> Vec { + std::iter::once(d.revision).chain(branch_tabs(s, d.uid).map(|t| t.revision)).collect() +} + +/// Write `doc` with its version history and branches to `path` through a temporary file; false +/// when it couldn't be written (the copy before it is still there). +fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool { + let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false }; let tmp = path.with_extension("tmp"); std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok() } @@ -257,9 +328,9 @@ pub fn recover(root: &Path, s: &mut Session) -> usize { } /// Open the copies left by runs that ended without closing (folders under `root` whose lock is -/// free, and copies directly in `root` from older versions) as unsaved documents, then remove them. -/// A copy that can't be read stays where it is. A copy of one of your online files is kept -/// instead while its file is joined again ([`Recovered::waiting`]). +/// free, and copies directly in `root` from older versions) as unsaved documents. Each is kept +/// until this run has its own copy of the document ([`Recovered::waiting`]); a copy of one of your +/// online files, while its file is joined again. A copy that can't be read stays where it is. pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { let mut out = Recovered::default(); let Ok(entries) = std::fs::read_dir(root) else { return out }; @@ -269,10 +340,15 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { } // One of your online files comes back from its server, its copy kept until then. if let Some((session, lock)) = lock.and_then(|l| Some((rejoin_online(file, s)?, l))) { - out.waiting.push(Waiting { session, copy: file.to_path_buf(), _lock: lock.clone() }); - } else if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() { + out.waiting.push(Waiting { until: Until::Session(session), copy: file.to_path_buf(), _lock: Some(lock.clone()) }); + return; + } + let before: Vec = s.docs.iter().map(|d| d.uid).collect(); + if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() { out.copies += 1; - let _ = std::fs::remove_file(file); + if let Some(uid) = s.docs.iter().map(|d| d.uid).find(|u| !before.contains(u)) { + out.waiting.push(Waiting { until: Until::Copied(uid), copy: file.to_path_buf(), _lock: lock.cloned() }); + } } }; for e in entries.flatten() { @@ -300,7 +376,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == ONLINE) && !f.with_extension("figcraft").exists()) { let _ = std::fs::remove_file(f); } - // Empty now unless a copy couldn't be read, or waits for its file (kept, its lock held). + // Empty now unless a copy couldn't be read, or is kept (its lock held). if Arc::strong_count(&lock) == 1 { drop(lock); let _ = std::fs::remove_file(path.join(LOCK)); diff --git a/apps/figcraft/src/recovery_online_tests.rs b/apps/figcraft/src/recovery_online_tests.rs index ead3749a..c9036773 100644 --- a/apps/figcraft/src/recovery_online_tests.rs +++ b/apps/figcraft/src/recovery_online_tests.rs @@ -223,6 +223,30 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() { let _ = std::fs::remove_dir_all(&root); } +#[test] +fn an_online_copy_stays_while_what_it_became_cant_be_copied() { + let root = root("ahead-blocked"); + let (mut s, script, mut server, _) = shared(); + s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap(); + script.lock().unwrap().sent.clear(); + let left = crash(&s, &root); + let (mut next, script, mut r, _) = restart(&s, &root); + arrive(&mut next, &script, &mut server); + assert!(next.docs[1].is_dirty(), "the copy's edit is on top, not saved anywhere yet"); + // Something is in the way of this run's copy: writing it fails. + let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[1].uid)); + std::fs::create_dir_all(&mine).unwrap(); + r.tick(&next, 1.0); + assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1), "kept: the work is nowhere else on disk"); + // Once it can be written, the old copy goes. + std::fs::remove_dir(&mine).unwrap(); + next.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 4, "height": 4})).unwrap(); + r.tick(&next, 2.0); + assert!(!left.exists()); + drop(r); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn a_copy_the_server_already_has_is_discarded() { let root = root("equal"); @@ -332,7 +356,7 @@ fn without_its_record_the_copy_opens_as_any_other() { assert_eq!(recover(&root, &mut next), 1); assert!(script.lock().unwrap().joined.is_empty()); assert!(next.docs.first().is_some_and(|d| d.is_dirty() && d.path.is_none())); - assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone"); + assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own"); let _ = std::fs::remove_dir_all(&root); } diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index bada7d98..a3d608f9 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -4,7 +4,7 @@ use figcraft_engine::Session; use serde_json::json; -use crate::recovery::{Recovery, recover}; +use crate::recovery::{Recovery, recover, recover_all}; fn root(name: &str) -> std::path::PathBuf { let r = std::env::temp_dir().join(format!("figcraft-recovery-{name}-{}", std::process::id())); @@ -34,7 +34,7 @@ fn a_crashed_runs_unsaved_documents_come_back() { let ds = next.docs.first().unwrap(); assert!(ds.is_dirty() && ds.path.is_none()); assert_eq!(next.doc().unwrap().node(next.doc().unwrap().pages[0]).map(|p| p.children.len()), Some(1)); - assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone"); + assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own"); let _ = std::fs::remove_dir_all(&root); } @@ -63,6 +63,239 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() { let _ = std::fs::remove_dir_all(&root); } +/// A run that ended without closing, leaving a copy of `s`'s unsaved document: its folder (as +/// another process's). +fn crashed(s: &Session, root: &std::path::Path) -> std::path::PathBuf { + let mut r = Recovery::start(root).unwrap(); + r.write(s, true); + drop(r); + let left = root.join("crashed"); + std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap(); + left +} + +fn copies_in(d: &std::path::Path) -> usize { + std::fs::read_dir(d).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count()) +} + +/// The next start: the copies come back, and this run's recovery keeps them until it covers them. +fn restart(root: &std::path::Path) -> (Session, Recovery) { + let mut next = Session::empty(); + let recovered = recover_all(root, &mut next); + assert_eq!(recovered.copies, 1); + let mut r = Recovery::start(root).unwrap(); + r.adopt(recovered.waiting); + (next, r) +} + +#[test] +fn a_recovered_copy_stays_until_this_run_has_its_own_copy() { + let root = root("handoff"); + let left = crashed(&edited(), &root); + let (next, mut r) = restart(&root); + assert_eq!(copies_in(&left), 1, "the only copy on disk until this run writes one"); + r.write(&next, true); + r.tick(&next, 100.0); + assert!(!left.exists(), "covered by this run's copy, the old one goes"); + assert_eq!(copies_in(&root.join(std::process::id().to_string())), 1); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_recovered_copy_stays_while_its_replacement_cant_be_written() { + let root = root("blocked"); + let left = crashed(&edited(), &root); + let (next, mut r) = restart(&root); + // Something is in the way of this run's copy: writing it fails. + let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[0].uid)); + std::fs::create_dir_all(&mine).unwrap(); + r.write(&next, true); + r.tick(&next, 100.0); + assert_eq!(copies_in(&left), 1, "kept: the work is nowhere else on disk"); + // Once it can be written, the old copy goes. + std::fs::remove_dir(&mine).unwrap(); + let mut next = next; + next.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 2, "height": 2})).unwrap(); + r.write(&next, true); + r.tick(&next, 200.0); + assert!(!left.exists()); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn without_a_recovery_of_its_own_the_copy_stays_for_the_next_start() { + let root = root("nocopies"); + crashed(&edited(), &root); + let mut next = Session::empty(); + let recovered = recover_all(&root, &mut next); + assert_eq!(recovered.copies, 1); + // This run couldn't take a folder of its own: it ends (or crashes) with nothing copied. + drop(recovered); + let mut after = Session::empty(); + assert_eq!(recover(&root, &mut after), 1, "the work comes back again"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_recovered_copy_goes_once_its_document_is_saved_or_closed() { + for close in [false, true] { + let root = root(if close { "closed" } else { "saved" }); + let left = crashed(&edited(), &root); + let (mut next, mut r) = restart(&root); + if close { + next.docs.clear(); + } else { + next.ds_mut().unwrap().mark_saved(); + } + r.tick(&next, 0.0); + assert!(!left.exists(), "close {close}"); + r.finish(); + let _ = std::fs::remove_dir_all(&root); + } +} + +#[test] +fn a_normal_quit_removes_the_recovered_copies_it_took_over() { + let root = root("quitafter"); + let left = crashed(&edited(), &root); + let (_, r) = restart(&root); + // Quitting asked to save or discard every document. + r.finish(); + assert!(!left.exists()); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_run_whose_folder_name_is_still_held_takes_another() { + let root = root("pidreuse"); + // A crashed run with this process's id whose folder the start still holds (its copy waits). + let held = root.join(std::process::id().to_string()); + std::fs::create_dir_all(&held).unwrap(); + let lock = std::fs::File::create(held.join("lock")).unwrap(); + lock.try_lock().unwrap(); + let mut r = Recovery::start(&root).expect("a folder of its own"); + let s = edited(); + r.write(&s, true); + assert_eq!(copies_in(&held), 0); + let dirs = std::fs::read_dir(&root).unwrap().flatten().filter(|e| e.path() != held).count(); + assert_eq!(dirs, 1); + r.finish(); + drop(lock); + let _ = std::fs::remove_dir_all(&root); +} + +/// The rectangles in `doc`. +fn rectangles(doc: &figcraft_engine::doc::Document) -> usize { + doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count() +} + +/// The documents a crashed run's copies under `root` open as. +fn crash_and_recover(r: Recovery, root: &std::path::Path) -> Session { + drop(r); + let mut next = Session::empty(); + assert_eq!(recover(root, &mut next), 1); + next +} + +#[test] +fn copies_keep_the_version_history_and_branches() { + // #185: a copy has what a save would write, the latest edits on a branch tab included. + let root = root("history"); + let mut s = edited(); + s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap(); + s.execute("branch.create", &json!({"name": "Explore"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + let mut r = Recovery::start(&root).unwrap(); + r.write(&s, true); + let next = crash_and_recover(r, &root); + let ds = next.docs.first().unwrap(); + assert_eq!(ds.versions.iter().map(|v| v.name.as_str()).collect::>(), ["v1"]); + assert_eq!(ds.branches.iter().map(|b| (b.name.as_str(), rectangles(&b.head))).collect::>(), [("Explore", 2)]); + let _ = std::fs::remove_dir_all(&root); +} + +/// The one copy under `root` (the run's folder), read back. +fn copy(root: &std::path::Path) -> figcraft_format::Loaded { + let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path(); + let file = std::fs::read_dir(dir).unwrap().flatten().map(|f| f.path()).find(|f| f.extension().is_some_and(|e| e == "figcraft")).unwrap(); + figcraft_format::load_bytes_with_versions(&std::fs::read(file).unwrap()).unwrap() +} + +#[test] +fn a_change_only_on_a_branch_tab_or_to_the_history_is_copied() { + let root = root("history-only"); + let mut s = edited(); + s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap(); + s.execute("branch.create", &json!({"name": "Explore"})).unwrap(); + for d in &mut s.docs { + d.mark_saved(); + } + let mut r = Recovery::start(&root).unwrap(); + // An edit on the branch tab only: the main file has no changes of its own yet. + s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + r.write(&s, true); + assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::>(), [2]); + s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap(); + r.write(&s, true); + assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::>(), [3]); + // A version renamed on the main file leaves its document as it was. + s.execute("doc.switch", &json!({"index": 0})).unwrap(); + r.write(&s, true); + let id = s.docs[0].versions[0].id.clone(); + s.execute("version.rename", &json!({"id": id, "name": "v1 (renamed)"})).unwrap(); + r.write(&s, true); + assert_eq!(copy(&root).versions.iter().map(|v| v.name.as_str()).collect::>(), ["v1 (renamed)"]); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} + +fn slow_write(path: std::path::PathBuf) -> std::thread::JoinHandle { + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(150)); + std::fs::write(path, b"stale").is_ok() + }) +} + +#[test] +fn saving_or_closing_waits_for_a_slow_write_so_the_copy_does_not_come_back() { + let root = root("slow-cleanup"); + let mut s = edited(); + let mut r = Recovery::start(&root).unwrap(); + let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); + let uid = s.docs[0].uid; + let copy = dir.join(format!("{uid}.figcraft")); + r.hold(uid, 1, slow_write(copy.clone())); + s.ds_mut().unwrap().mark_saved(); + r.write(&s, false); + std::thread::sleep(std::time::Duration::from_millis(400)); + assert!(!copy.exists(), "a saved document's copy stays removed"); + // Closed: the same. + let s = edited(); + let uid = s.docs[0].uid; + let copy = dir.join(format!("{uid}.figcraft")); + r.hold(uid, 1, slow_write(copy.clone())); + r.write(&Session::empty(), false); + std::thread::sleep(std::time::Duration::from_millis(400)); + assert!(!copy.exists(), "a closed document's copy stays removed"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn an_emergency_save_waits_for_the_write_in_flight_and_copies_the_newest_revision() { + let root = root("slow-emergency"); + let s = edited(); + let mut r = Recovery::start(&root).unwrap(); + let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); + let uid = s.docs[0].uid; + let copy = dir.join(format!("{uid}.figcraft")); + r.hold(uid, 0, slow_write(copy.clone())); + r.write(&s, true); + assert!(std::fs::read(©).is_ok_and(|b| b != b"stale"), "the newest revision replaced the slow write"); + let _ = std::fs::remove_dir_all(&root); +} + /// The files named `*.figcraft` in the run folder under `root`. fn copy_count(root: &std::path::Path) -> usize { let Some(dir) = std::fs::read_dir(root).unwrap().flatten().next().map(|e| e.path()) else { return 0 }; diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index 4a14a73a..bd8a4204 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -1,11 +1,12 @@ //! Desktop services: native file dialogs, clipboard, URLs, persisted preferences. +use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use figcraft_engine::{Prefs, Session}; -use figcraft_ui_egui::{FigApp, Services}; +use figcraft_ui_egui::{DialogFuture, FigApp, Services}; use serde_json::{Value, json}; pub struct Desktop { @@ -198,15 +199,39 @@ fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { f.write_all(bytes) } +thread_local! { + /// While the main window draws: a dialog parented to it, so a dialog opened from it stays in + /// front of it (a sheet on macOS). eframe gives other windows no handle: theirs have no parent. + static PARENT: RefCell> = const { RefCell::new(None) }; +} + +/// Run the main window's frame `f` with the dialogs it opens parented to `frame`'s window. +pub fn parented(frame: &eframe::Frame, f: impl FnOnce() -> R) -> R { + PARENT.with(|p| p.replace(Some(rfd::AsyncFileDialog::new().set_parent(frame)))); + let r = f(); + PARENT.with(|p| p.replace(None)); + r +} + +/// A dialog with `title`, parented to the main window while it draws. +fn dialog(title: &str) -> rfd::AsyncFileDialog { + PARENT.with(|p| p.borrow().clone()).unwrap_or_default().set_title(title) +} + +/// The dialog opens now; its future gives the path picked. +fn picked(pick: impl Future> + 'static) -> DialogFuture { + Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) }) +} + impl Services for Desktop { - fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option { - rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file() + fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { + picked(dialog(title).add_filter("Supported files", exts).pick_file()) } - fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> Option { - rfd::FileDialog::new().set_title(title).set_file_name(name).add_filter("File", exts).save_file() + fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { + picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file()) } - fn pick_folder(&mut self, title: &str) -> Option { - rfd::FileDialog::new().set_title(title).pick_folder() + fn pick_folder(&mut self, title: &str) -> DialogFuture { + picked(dialog(title).pick_folder()) } fn clipboard_set_text(&mut self, s: &str) { if let Some(c) = self.clipboard.as_mut() { @@ -246,6 +271,8 @@ impl Services for Desktop { fn emergency_save(&mut self, session: &Session) { if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() { r.write(session, true); + // Earlier runs' copies now covered go, so the next start doesn't open them twice. + r.settle(session); } } fn reduce_motion(&self) -> bool { diff --git a/apps/figcraft/src/startup.rs b/apps/figcraft/src/startup.rs new file mode 100644 index 00000000..a25821f3 --- /dev/null +++ b/apps/figcraft/src/startup.rs @@ -0,0 +1,27 @@ +//! When the window can't open. +//! +//! Release builds on Windows have no console, so an error from `eframe::run_native` (no usable +//! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees +//! and the app quit without a word (#261). Now it is logged and shown in a message box. + +/// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would +/// keep the caller waiting, and it reads standard error), show it in a message box. +pub fn failed(error: &eframe::Error, quiet: bool) { + // In the interface language: the text and its catalog entries are the UI crate's. + let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some()); + // Standard error even when RUST_LOG turned errors off. + if log::log_enabled!(log::Level::Error) { + log::error!("{text}"); + } else { + eprintln!("figcraft: {text}"); + } + if quiet { + return; + } + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("FigCraft") + .set_description(text) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} diff --git a/apps/figcraft/src/startup_tests.rs b/apps/figcraft/src/startup_tests.rs new file mode 100644 index 00000000..0c92bd19 --- /dev/null +++ b/apps/figcraft/src/startup_tests.rs @@ -0,0 +1,35 @@ +//! The message shown when the window can't open: it carries the error, a next step and where to +//! report it, instead of the app quitting silently (#261). + +use figcraft_ui_egui::dialogs::window_failed as message; + +const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}"; + +#[test] +fn the_message_carries_the_error_and_where_to_report_it() { + let text = message(ERROR, false); + assert!(text.contains(ERROR), "the error itself: {text}"); + assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}"); +} + +#[test] +fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() { + let text = message(ERROR, false); + assert!(text.contains("graphics driver"), "{text}"); + assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}"); + assert!(!text.contains("unsetting"), "{text}"); +} + +#[cfg(windows)] +#[test] +fn on_windows_it_shows_how_to_start_with_opengl() { + let text = message(ERROR, false); + assert!(text.contains("set WGPU_BACKEND=gl"), "a release build has no console to look this up from: {text}"); +} + +#[test] +fn with_wgpu_backend_set_it_suggests_unsetting_it() { + let text = message(ERROR, true); + assert!(text.contains("unsetting"), "{text}"); + assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}"); +} diff --git a/contributors/people.toml b/contributors/people.toml index af5d0576..bc8cf71b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,13 +17,30 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.VanTan666] +display_name = "VanTan" + [people.tylerjenningsw] real_name = "Tyler Jennings" display_name = "tylerjenningsw" -[people.VanTan666] -display_name = "VanTan" - [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.yyamandevrim] +real_name = "Yaman Devrim" +sync_github_name = true + +[people.justinjohn0306] +real_name = "Justin John" + +[people.Losminek] + +[people.RevDonz] + +[people.Nael-Nathanael] +sync_github_name = true + +[people.volkovskey] +real_name = "Andrii Volkov" diff --git a/crates/collab-net/src/barred_tests.rs b/crates/collab-net/src/barred_tests.rs new file mode 100644 index 00000000..c9355a5f --- /dev/null +++ b/crates/collab-net/src/barred_tests.rs @@ -0,0 +1,53 @@ +//! Addresses removed from a file are kept only while its session lasts, so a long-running server +//! doesn't collect them for every file it ever had open. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr}; + +use figcraft_collab::{Limits, Server}; +use figcraft_doc::Document; + +use super::{Admission, Backend, Closing, Hub, InFlight, Room}; + +/// A backend that closes a file as soon as everyone left it. +struct Closes; + +impl Backend for Closes { + fn admit(&mut self, _file: &str, _token: &str) -> Result { + Err("no".into()) + } + + fn open(&mut self, _file: &str) -> Result { + Err("no".into()) + } + + fn idle(&mut self, _file: &str, _server: &mut Server) -> bool { + true + } + + fn closing(&self) -> Closing { + Closing::Restarting + } +} + +#[test] +fn a_closed_file_forgets_whom_it_barred() { + let mut doc = Document::new("File"); + doc.repair(); + let mut hub = Hub { + in_flight: InFlight(Default::default()), + backend: Closes, + rooms: HashMap::from([("f".to_string(), Room::new(Server::new(doc, "", Limits::default())))]), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + barred: HashMap::new(), + }; + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7)); + hub.barred.entry("f".into()).or_default().insert(ip); + hub.barred.entry("other".into()).or_default().insert(ip); + hub.close_if_empty("f"); + assert!(!hub.rooms.contains_key("f"), "everyone left: the file closed"); + assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it"); + assert!(hub.barred.contains_key("other"), "another file's stay"); +} diff --git a/crates/collab-net/src/host.rs b/crates/collab-net/src/host.rs index 0d51735e..24be22d3 100644 --- a/crates/collab-net/src/host.rs +++ b/crates/collab-net/src/host.rs @@ -11,6 +11,7 @@ use figcraft_collab::{Outgoing, Server, same_secret}; use figcraft_doc::Document; use figcraft_engine::live::{Event, PEOPLE}; +pub use crate::hub::REMOVED; use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local}; use crate::inbox::Inbox; pub use crate::join::ENDED; @@ -129,6 +130,12 @@ impl Host { self.hosting.send(msg); } + /// Remove client `id` from the session: they are disconnected and can't join again from + /// their address while it runs. The host can't remove themselves. + pub fn remove(&self, id: u32) { + self.hosting.remove(id); + } + /// Cut every connection as a network failure would (no close frame). #[cfg(test)] pub(crate) fn cut_connections(&self) { diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 24474211..5cf3e519 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -21,7 +21,7 @@ //! backend may refuse addresses or limit what they do. A plain `GET /healthz` on the same port is //! answered `200 ok` for uptime checks. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::{self, Read, Write}; use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -413,6 +413,8 @@ enum HubIn { }, Local(Presence), LocalMsg(ClientMsg), + /// The person hosting removes client `id` from their file (app sessions). + Remove(u32), /// Cut every connection without a word (tests: a network failure). #[cfg(test)] Cut, @@ -461,7 +463,15 @@ impl Hosting { pub(crate) fn start(listener: TcpListener, backend: B, limits: NetLimits, local: Option) -> io::Result { let addr = listener.local_addr()?; let in_flight = InFlight(Arc::default()); - let mut hub = Hub { backend, rooms: HashMap::new(), conns: HashMap::new(), local: None, outbox: Vec::new(), in_flight: in_flight.clone() }; + let mut hub = Hub { + backend, + rooms: HashMap::new(), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + in_flight: in_flight.clone(), + barred: HashMap::new(), + }; if let Some(l) = local { let mut room = Room::new(l.server); room.local = Some((l.id, l.inbox)); @@ -551,6 +561,12 @@ impl Hosting { let _ = self.hub.send(HubIn::LocalMsg(msg)); } + /// The hosting person removes client `id` from their file (app sessions; see + /// [`HubIn::Remove`]). + pub(crate) fn remove(&self, id: u32) { + let _ = self.hub.send(HubIn::Remove(id)); + } + /// Cut every connection as a network failure would (no close frame). #[cfg(test)] pub(crate) fn cut_connections(&self) { @@ -1108,6 +1124,16 @@ fn unsupported(protocol: u32) -> String { /// sharing): the session's end for them. pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open"; +/// What someone removed from a file is told: the session's end for them (they keep their copy; +/// nothing retries). +pub const REMOVED: &str = "You were removed from this session. Your copy stays open"; + +/// Why a removed person's address can't join the file again. +const BARRED: &str = "you were removed from this session"; + +/// The most addresses one file keeps barred. +const MAX_BARRED: usize = 256; + /// What everyone in a file is told when its changes couldn't be saved (they reconnect). const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting"; @@ -1121,6 +1147,9 @@ struct Hub { local: Option, /// What the servers said, sent once the backend made what changed durable. outbox: Vec<(String, Outgoing)>, + /// Addresses ([`ip_key`]) removed from each open file: they can't join it again until its + /// session closes (the file is closed or removed; the entry goes with it). + barred: HashMap>, } impl Hub { @@ -1208,6 +1237,13 @@ impl Hub { self.client_message(&file, id, msg, None); } } + HubIn::Remove(id) => { + if let Some(file) = self.local.clone() + && let Err(why) = self.remove_peer(&file, id) + { + log::info!("live session: couldn't remove client {id}: {why}"); + } + } #[cfg(test)] HubIn::Cut => { for c in self.conns.values() { @@ -1232,6 +1268,14 @@ impl Hub { self.client_message(&file, id, msg, Some(bytes)); } Some(Kind::Hosting { owns }) => match msg { + ClientMsg::Admin(Admin::RemovePeer { file, client }) => { + let reply = if owns.as_deref() == Some(file.as_str()) { + self.removed_reply(&file, client) + } else { + AdminReply::Failed { reason: "an owner token manages only its own file".into() } + }; + self.answer(conn, &ServerMsg::Admin(Box::new(reply))); + } ClientMsg::Admin(req) => { let owns = owns.clone(); let rooms = &self.rooms; @@ -1243,6 +1287,10 @@ impl Hub { _ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"), }, Some(Kind::Managing) => match msg { + ClientMsg::Admin(Admin::RemovePeer { file, client }) => { + let reply = self.removed_reply(&file, client); + self.answer(conn, &ServerMsg::Admin(Box::new(reply))); + } ClientMsg::Admin(req) => self.admin(conn, req), ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), _ => self.kick(&[conn], "only requests to manage files are spoken on this connection"), @@ -1305,6 +1353,10 @@ impl Hub { Ok(a) => a, Err(why) => return refuse(&c.out, &why), }; + if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) { + log::info!("event=refused ip={} reason=removed", c.ip); + return refuse(&c.out, BARRED); + } let hosting = reads_hosting(&hello.client_version); // The backend checked the secret: the sessions don't keep it. hello.token.clear(); @@ -1362,6 +1414,34 @@ impl Hub { } } + /// Remove client `id` from `file`: they are told the session is over for them and + /// disconnected, and their address can't join the file again while its session lasts. The + /// person hosting can't be removed. + fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> { + let room = self.rooms.get(file).ok_or("nobody is in this file")?; + if room.local.as_ref().is_some_and(|(local, _)| *local == id) { + return Err("the person hosting can't be removed".into()); + } + let conn = *room.ids.get(&id).ok_or_else(|| format!("nobody with client id {id} is in this file"))?; + let c = self.conns.get(&conn).ok_or_else(|| format!("nobody with client id {id} is in this file"))?; + let barred = self.barred.entry(file.to_string()).or_default(); + if barred.len() < MAX_BARRED { + barred.insert(c.ip); + } + log::info!("event=removed file={file} client={id} ip={}", c.ip); + let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())); + self.gone(conn); + Ok(()) + } + + /// [`remove_peer`](Self::remove_peer) as a managing connection's answer. + fn removed_reply(&mut self, file: &str, id: u32) -> AdminReply { + match self.remove_peer(file, id) { + Ok(()) => AdminReply::Done, + Err(reason) => AdminReply::Failed { reason }, + } + } + /// Take in connection `c` as `kind` and send it `first`. fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) { self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip }); @@ -1479,6 +1559,7 @@ impl Hub { .collect(); // Closed without saving: the file is gone. self.rooms.remove(&file); + self.barred.remove(&file); self.kick(&users, "this file was removed from the server"); } if let Some(ip) = handled.blocked { @@ -1556,6 +1637,7 @@ impl Hub { let Some(room) = self.rooms.get_mut(file).filter(|r| r.empty()) else { return }; if self.backend.idle(file, &mut room.server) { self.rooms.remove(file); + self.barred.remove(file); } } @@ -1661,3 +1743,7 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) { #[cfg(test)] #[path = "queue_tests.rs"] mod queue_tests; + +#[cfg(test)] +#[path = "barred_tests.rs"] +mod barred_tests; diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 754f1529..a84395aa 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -243,6 +243,18 @@ impl LiveLink for NetLink { } } + fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> { + match self.sessions.get(session) { + Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()), + Some(Active::Host(h)) => { + h.remove(client); + Ok(()) + } + Some(Active::Guest(_)) => Err("only the person hosting can remove people from this session".into()), + None => Err("this session is over".into()), + } + } + fn leave(&mut self, session: SessionId) { if let Some(active) = self.sessions.remove(session) { active.end(); diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 814d0004..32c86bd0 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -15,7 +15,7 @@ use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; use crate::guest::{Guest, GuestConfig}; -use crate::host::{ENDED, Host, HostConfig}; +use crate::host::{ENDED, Host, HostConfig, REMOVED}; use crate::inbox::Inbox; use crate::invite::Invite; use crate::net::{Bucket, NetLimits}; @@ -611,3 +611,34 @@ fn until_presence_or_pong(r: &mut Raw) -> Option { } } } + +#[test] +fn the_host_removes_someone_and_they_cant_come_back() { + let (mut host, mut hp, invite) = start(); + let (_a, mut ap) = join(&invite, "Ada"); + let (b, mut bp) = join(&invite, "Bo"); + ap.wait("welcome", welcomed); + bp.wait("welcome", welcomed); + let (ada, bo) = (ap.me(), bp.me()); + connections_settle(&host, 2); + // The host can't remove themselves, nor someone who isn't there: nothing happens. + host.remove(host.me().client_id); + host.remove(9999); + host.remove(ada); + // Ada is told the session is over for her (no reconnecting); the others see her leave. + ap.wait("Ada removed", |e| e.iter().any(|e| matches!(e, Event::Ended { reason } if reason == REMOVED))); + hp.wait("the host sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada))); + bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada))); + connections_settle(&host, 1); + assert!(!ap.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a removed guest doesn't reconnect"); + assert!(!hp.left().contains(&bo), "only Ada was removed"); + // Her address can't join again with the same invite (every guest here comes from 127.0.0.1). + let (_again, mut againp) = join(&invite, "Ada"); + againp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("removed")))); + assert!(!welcomed(&againp.events)); + // Bo, already in, stays and can still talk to the host. + b.presence(presence(3.0, 4.0)); + hp.wait("Bo's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == bo))); + host.stop(); + assert_eq!(host.connections(), 0); +} diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 84931904..9f5d12fb 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -75,6 +75,7 @@ fn samples() -> (Vec, Vec) { ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }), ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }), ClientMsg::Admin(Admin::Blocked), + ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }), ClientMsg::Report { reason: "Spam ☕".into() }, ]; let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap(); diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 0eb0bd8c..d035042b 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -274,6 +274,10 @@ pub enum Admin { /// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them: /// [`AdminReply::Owned`]. Servers older than this request can't read it. Owned { files: Vec<(String, String)> }, + /// Remove client `client` from `file`'s session: they are disconnected and their address + /// can't join the file again while its session lasts. With the server key, or on an open + /// hosting connection with `file`'s owner token. Servers older than this request can't read it. + RemovePeer { file: String, client: u32 }, } /// The most files one [`Admin::Owned`] asks about. diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 7d0bae9c..d6859a51 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; mod blend; mod hsx; mod icc; +mod named; mod profile; pub use blend::BlendMode; pub use hsx::*; @@ -139,7 +140,13 @@ impl Color { Some(Self::from_rgba8(r, g, b, a)) } - /// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()` or a few common names. + /// What a colour field accepts: hex as [`Color::from_hex`] reads it, or a CSS colour name + /// (`red` → `FF0000`, `Cornflower Blue` → `6495ED`; opaque). + pub fn from_hex_or_name(s: &str) -> Option { + Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255))) + } + + /// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`. pub fn parse_css(s: &str) -> Option { let t = s.trim().to_ascii_lowercase(); if t.starts_with('#') { @@ -166,21 +173,10 @@ impl Color { let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); return Some(Hsl { h, s, l }.to_color().with_alpha(alpha)); } - let named = match t.as_str() { - "black" => Color::BLACK, - "white" => Color::WHITE, - "transparent" => Color::TRANSPARENT, - "red" => Color::from_rgb8_const(255, 0, 0), - "green" => Color::from_rgb8_const(0, 128, 0), - "lime" => Color::from_rgb8_const(0, 255, 0), - "blue" => Color::from_rgb8_const(0, 0, 255), - "yellow" => Color::from_rgb8_const(255, 255, 0), - "orange" => Color::from_rgb8_const(255, 165, 0), - "purple" => Color::from_rgb8_const(128, 0, 128), - "gray" | "grey" => Color::from_rgb8_const(128, 128, 128), - _ => return Self::from_hex(&t), - }; - Some(named) + if t == "transparent" { + return Some(Color::TRANSPARENT); + } + Self::from_hex_or_name(&t) } /// CSS `rgba(...)` / hex text for copy-as-CSS. diff --git a/crates/color/src/named.rs b/crates/color/src/named.rs new file mode 100644 index 00000000..882472cc --- /dev/null +++ b/crates/color/src/named.rs @@ -0,0 +1,197 @@ +//! The CSS named colours (CSS Color Module Level 4 §6.1), so a colour field takes `red` as well as +//! `FF0000`. + +/// Every CSS named colour except `transparent`, sorted by name for binary search. +const NAMED: [(&str, [u8; 3]); 148] = [ + ("aliceblue", [240, 248, 255]), + ("antiquewhite", [250, 235, 215]), + ("aqua", [0, 255, 255]), + ("aquamarine", [127, 255, 212]), + ("azure", [240, 255, 255]), + ("beige", [245, 245, 220]), + ("bisque", [255, 228, 196]), + ("black", [0, 0, 0]), + ("blanchedalmond", [255, 235, 205]), + ("blue", [0, 0, 255]), + ("blueviolet", [138, 43, 226]), + ("brown", [165, 42, 42]), + ("burlywood", [222, 184, 135]), + ("cadetblue", [95, 158, 160]), + ("chartreuse", [127, 255, 0]), + ("chocolate", [210, 105, 30]), + ("coral", [255, 127, 80]), + ("cornflowerblue", [100, 149, 237]), + ("cornsilk", [255, 248, 220]), + ("crimson", [220, 20, 60]), + ("cyan", [0, 255, 255]), + ("darkblue", [0, 0, 139]), + ("darkcyan", [0, 139, 139]), + ("darkgoldenrod", [184, 134, 11]), + ("darkgray", [169, 169, 169]), + ("darkgreen", [0, 100, 0]), + ("darkgrey", [169, 169, 169]), + ("darkkhaki", [189, 183, 107]), + ("darkmagenta", [139, 0, 139]), + ("darkolivegreen", [85, 107, 47]), + ("darkorange", [255, 140, 0]), + ("darkorchid", [153, 50, 204]), + ("darkred", [139, 0, 0]), + ("darksalmon", [233, 150, 122]), + ("darkseagreen", [143, 188, 143]), + ("darkslateblue", [72, 61, 139]), + ("darkslategray", [47, 79, 79]), + ("darkslategrey", [47, 79, 79]), + ("darkturquoise", [0, 206, 209]), + ("darkviolet", [148, 0, 211]), + ("deeppink", [255, 20, 147]), + ("deepskyblue", [0, 191, 255]), + ("dimgray", [105, 105, 105]), + ("dimgrey", [105, 105, 105]), + ("dodgerblue", [30, 144, 255]), + ("firebrick", [178, 34, 34]), + ("floralwhite", [255, 250, 240]), + ("forestgreen", [34, 139, 34]), + ("fuchsia", [255, 0, 255]), + ("gainsboro", [220, 220, 220]), + ("ghostwhite", [248, 248, 255]), + ("gold", [255, 215, 0]), + ("goldenrod", [218, 165, 32]), + ("gray", [128, 128, 128]), + ("green", [0, 128, 0]), + ("greenyellow", [173, 255, 47]), + ("grey", [128, 128, 128]), + ("honeydew", [240, 255, 240]), + ("hotpink", [255, 105, 180]), + ("indianred", [205, 92, 92]), + ("indigo", [75, 0, 130]), + ("ivory", [255, 255, 240]), + ("khaki", [240, 230, 140]), + ("lavender", [230, 230, 250]), + ("lavenderblush", [255, 240, 245]), + ("lawngreen", [124, 252, 0]), + ("lemonchiffon", [255, 250, 205]), + ("lightblue", [173, 216, 230]), + ("lightcoral", [240, 128, 128]), + ("lightcyan", [224, 255, 255]), + ("lightgoldenrodyellow", [250, 250, 210]), + ("lightgray", [211, 211, 211]), + ("lightgreen", [144, 238, 144]), + ("lightgrey", [211, 211, 211]), + ("lightpink", [255, 182, 193]), + ("lightsalmon", [255, 160, 122]), + ("lightseagreen", [32, 178, 170]), + ("lightskyblue", [135, 206, 250]), + ("lightslategray", [119, 136, 153]), + ("lightslategrey", [119, 136, 153]), + ("lightsteelblue", [176, 196, 222]), + ("lightyellow", [255, 255, 224]), + ("lime", [0, 255, 0]), + ("limegreen", [50, 205, 50]), + ("linen", [250, 240, 230]), + ("magenta", [255, 0, 255]), + ("maroon", [128, 0, 0]), + ("mediumaquamarine", [102, 205, 170]), + ("mediumblue", [0, 0, 205]), + ("mediumorchid", [186, 85, 211]), + ("mediumpurple", [147, 112, 219]), + ("mediumseagreen", [60, 179, 113]), + ("mediumslateblue", [123, 104, 238]), + ("mediumspringgreen", [0, 250, 154]), + ("mediumturquoise", [72, 209, 204]), + ("mediumvioletred", [199, 21, 133]), + ("midnightblue", [25, 25, 112]), + ("mintcream", [245, 255, 250]), + ("mistyrose", [255, 228, 225]), + ("moccasin", [255, 228, 181]), + ("navajowhite", [255, 222, 173]), + ("navy", [0, 0, 128]), + ("oldlace", [253, 245, 230]), + ("olive", [128, 128, 0]), + ("olivedrab", [107, 142, 35]), + ("orange", [255, 165, 0]), + ("orangered", [255, 69, 0]), + ("orchid", [218, 112, 214]), + ("palegoldenrod", [238, 232, 170]), + ("palegreen", [152, 251, 152]), + ("paleturquoise", [175, 238, 238]), + ("palevioletred", [219, 112, 147]), + ("papayawhip", [255, 239, 213]), + ("peachpuff", [255, 218, 185]), + ("peru", [205, 133, 63]), + ("pink", [255, 192, 203]), + ("plum", [221, 160, 221]), + ("powderblue", [176, 224, 230]), + ("purple", [128, 0, 128]), + ("rebeccapurple", [102, 51, 153]), + ("red", [255, 0, 0]), + ("rosybrown", [188, 143, 143]), + ("royalblue", [65, 105, 225]), + ("saddlebrown", [139, 69, 19]), + ("salmon", [250, 128, 114]), + ("sandybrown", [244, 164, 96]), + ("seagreen", [46, 139, 87]), + ("seashell", [255, 245, 238]), + ("sienna", [160, 82, 45]), + ("silver", [192, 192, 192]), + ("skyblue", [135, 206, 235]), + ("slateblue", [106, 90, 205]), + ("slategray", [112, 128, 144]), + ("slategrey", [112, 128, 144]), + ("snow", [255, 250, 250]), + ("springgreen", [0, 255, 127]), + ("steelblue", [70, 130, 180]), + ("tan", [210, 180, 140]), + ("teal", [0, 128, 128]), + ("thistle", [216, 191, 216]), + ("tomato", [255, 99, 71]), + ("turquoise", [64, 224, 208]), + ("violet", [238, 130, 238]), + ("wheat", [245, 222, 179]), + ("white", [255, 255, 255]), + ("whitesmoke", [245, 245, 245]), + ("yellow", [255, 255, 0]), + ("yellowgreen", [154, 205, 50]), +]; + +/// Longest name in [`NAMED`]; anything longer can't match. +const LONGEST: usize = 20; + +/// The opaque sRGB bytes of a CSS colour name. Case, spaces, `-` and `_` are ignored, so +/// `Light Blue`, `light-blue` and `lightblue` are the same colour. +pub fn lookup(s: &str) -> Option<[u8; 3]> { + let key: String = s.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').take(LONGEST + 1).collect::().to_ascii_lowercase(); + if key.is_empty() || key.len() > LONGEST { + return None; + } + NAMED.binary_search_by(|(name, _)| name.cmp(&key.as_str())).ok().and_then(|i| NAMED.get(i)).map(|(_, rgb)| *rgb) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_is_sorted_and_unique() { + assert!(NAMED.windows(2).all(|w| w[0].0 < w[1].0)); + assert_eq!(NAMED.iter().map(|(n, _)| n.len()).max(), Some(LONGEST)); + } + + #[test] + fn every_name_is_found() { + for (name, rgb) in NAMED { + assert_eq!(lookup(name), Some(rgb), "{name}"); + } + } + + #[test] + fn lookup_normalises() { + assert_eq!(lookup("Red"), Some([255, 0, 0])); + assert_eq!(lookup(" CORNFLOWER blue "), Some([100, 149, 237])); + assert_eq!(lookup("light-goldenrod_yellow"), Some([250, 250, 210])); + assert_eq!(lookup(""), None); + assert_eq!(lookup("transparent"), None); + assert_eq!(lookup("reddish"), None); + assert_eq!(lookup("rédé"), None); + assert_eq!(lookup(&"a".repeat(10_000)), None); + } +} diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index facb3415..68a838e0 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -17,6 +17,23 @@ fn css_parsing() { assert_eq!(Color::parse_css("rgba(0 0 255 / 50%)").unwrap().to_rgba8(), [0, 0, 255, 128]); assert_eq!(Color::parse_css("hsl(120, 100%, 50%)").unwrap().to_hex(), "00FF00"); assert_eq!(Color::parse_css("white").unwrap(), Color::WHITE); + assert_eq!(Color::parse_css("RebeccaPurple").unwrap().to_hex(), "663399"); + assert_eq!(Color::parse_css("transparent").unwrap(), Color::TRANSPARENT); +} + +#[test] +fn colour_fields_take_names() { + assert_eq!(Color::from_hex_or_name("red").unwrap().to_css_hex(), "#FF0000"); + assert_eq!(Color::from_hex_or_name(" Cornflower Blue ").unwrap().to_hex(), "6495ED"); + assert_eq!(Color::from_hex_or_name("darkblue").unwrap().to_rgba8(), [0, 0, 139, 255]); + // Hex wins: every digit string still reads as hex. + assert_eq!(Color::from_hex_or_name("abc").unwrap().to_hex(), "AABBCC"); + assert_eq!(Color::from_hex_or_name("#0D99FF").unwrap().to_hex(), "0D99FF"); + // Names are opaque; `transparent` is not a field colour. + assert!(Color::from_hex_or_name("transparent").is_none()); + for junk in ["", "#", "redd", "re d d", "12345", "\u{0}", "🟥", &"x".repeat(100_000)] { + assert!(Color::from_hex_or_name(junk).is_none(), "{junk:?}"); + } } #[test] diff --git a/crates/doc/src/proto_expr.rs b/crates/doc/src/proto_expr.rs index 2ec8e91f..f4404dc7 100644 --- a/crates/doc/src/proto_expr.rs +++ b/crates/doc/src/proto_expr.rs @@ -143,7 +143,7 @@ impl Expr { return "…".into(); } match self { - Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| v.name.clone()), + Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)), Expr::Value(VariableValue::String(s)) => quote(s), // Exact numbers and `#` colours, so the text reads back as the same expression. Expr::Value(VariableValue::Number(n)) => format!("{n}"), @@ -178,6 +178,18 @@ impl Expr { } } +/// A variable as written in an expression: its bare name when that reads back as this variable, +/// otherwise `{name}` (or `{id}` when even the braced name wouldn't), so names like `1`, `2x` or +/// `"q"` don't read back as literals or errors. +fn var_ref(vars: &Variables, v: &crate::Variable) -> String { + let this = Expr::var(v.id.clone()); + if Expr::parse(&v.name, vars).is_ok_and(|e| e == this) { + return v.name.clone(); + } + let braced = !v.name.contains('}') && v.name.trim() == v.name && vars.get(&v.name).is_some_and(|x| x.id == v.id); + if braced { format!("{{{}}}", v.name) } else { format!("{{{}}}", v.id) } +} + /// A collection or mode name as written inside `mode(…)` or `[…]`: as it is, or quoted when it has /// spaces at its ends, quotes or brackets. fn name_arg(name: &str) -> String { diff --git a/crates/doc/src/proto_expr_parse_tests.rs b/crates/doc/src/proto_expr_parse_tests.rs index 52f909fd..e106121d 100644 --- a/crates/doc/src/proto_expr_parse_tests.rs +++ b/crates/doc/src/proto_expr_parse_tests.rs @@ -176,3 +176,22 @@ fn hostile_text_is_bounded() { } } } + +#[test] +fn names_that_look_like_literals_read_back_as_variables() { + for name in ["count", "count total", "2x", "1", "-3", ".5", "(x)", "#ff", "\"q\"", "not", "true"] { + let mut v = vars(); + if let Some(var) = v.variables.iter_mut().find(|x| x.id == "n") { + var.name = name.into(); + } + for e in [Expr::var("n"), Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]), Expr::InMode { variable: "n".into(), mode: "dark".into() }] { + let shown = e.display(&v); + assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}"); + } + } + // Plain names stay bare, and literals still read as literals. + let v = vars(); + assert_eq!(Expr::var("n").display(&v), "n-name"); + assert_eq!(parse("1"), n(1.0)); + assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into()))); +} diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index d54bb29b..43031438 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -381,3 +381,25 @@ fn custom_easings() { assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 }); assert_eq!(serde_json::from_value::(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut); } + +#[test] +fn text_style_empty_range_links_and_unlinks_all() { + let def = TextStyle { font_style: "Bold".into(), ..Default::default() }; + let mut t = TextData::new("abcd"); + t.link_text_style(2, 2, &def, "S"); + assert!((0..4).all(|i| t.style_at(i).style_id.as_deref() == Some("S") && t.style_at(i).font_style == "Bold")); + t.unlink_text_style(2, 2); + assert!((0..4).all(|i| t.style_at(i).style_id.is_none() && t.style_at(i).font_style == "Bold")); + // Partial ranges stay partial. + t.link_text_style(0, 4, &def, "S"); + t.unlink_text_style(1, 3); + assert_eq!(t.style_at(0).style_id.as_deref(), Some("S")); + assert!(t.style_at(1).style_id.is_none() && t.style_at(2).style_id.is_none()); + assert_eq!(t.style_at(3).style_id.as_deref(), Some("S")); + // Empty text changes the base style. + let mut e = TextData::new(""); + e.link_text_style(0, 0, &def, "S"); + assert_eq!(e.style.style_id.as_deref(), Some("S")); + e.unlink_text_style(0, 0); + assert!(e.style.style_id.is_none()); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 8018609d..3ddf6559 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -675,11 +675,13 @@ impl TextData { /// Link chars `start..end` (all of them when the range is empty or the whole text) to text /// style `id`, defined as `def`. pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; self.restyle(start, end, |s| s.take_text_style(def, id)); } /// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay. pub fn unlink_text_style(&mut self, start: usize, end: usize) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; self.restyle(start, end, |s| s.style_id = None); } diff --git a/crates/doc/src/vector_faces.rs b/crates/doc/src/vector_faces.rs index a34c5036..050ab821 100644 --- a/crates/doc/src/vector_faces.rs +++ b/crates/doc/src/vector_faces.rs @@ -136,8 +136,8 @@ impl VectorNetwork { } let k = key(&face.loops); match (self.regions.iter().position(|r| key(&r.loops) == k), want) { - (Some(i), None | Some(false)) => { - self.regions.remove(i); + (Some(_), None | Some(false)) => { + self.regions.retain(|r| key(&r.loops) != k); Some(false) } (Some(_), Some(true)) => Some(true), @@ -525,4 +525,21 @@ mod tests { let _ = j.paint(Point::new(f64::NAN, 5.0)); assert!(interior_point(&BezPath::new()).is_none()); } + + #[test] + fn the_bucket_unfills_a_face_with_duplicate_regions_in_one_click() { + let c = Point::new(50.0, 50.0); + let mut n = square(0.0, 0.0, 100.0); + n.regions.push(n.regions[0].clone()); + assert_eq!(n.paint(c), Some(false)); + assert!(!n.fill_contains(c)); + assert_eq!(n.region_at(c), None); + assert_eq!(n.paint(c), Some(true)); + assert!(n.fill_contains(c)); + + let mut e = square(0.0, 0.0, 100.0); + e.regions.push(e.regions[0].clone()); + assert_eq!(e.paint_to(c, Some(false)), Some(false)); + assert!(!e.fill_contains(c)); + } } diff --git a/crates/doc/src/vector_regions.rs b/crates/doc/src/vector_regions.rs index 1d1cd794..cc456bef 100644 --- a/crates/doc/src/vector_regions.rs +++ b/crates/doc/src/vector_regions.rs @@ -18,8 +18,9 @@ impl VectorNetwork { /// second; a segment that doesn't touch the point reached so far (a malformed loop) is taken /// as stored. Missing segments are skipped. pub fn loop_walk(&self, lp: &[usize]) -> Vec<(usize, bool)> { - let Some(first) = lp.first().and_then(|i| self.segments.get(*i)) else { return Vec::new() }; - let forward_first = match lp.get(1).and_then(|i| self.segments.get(*i)) { + let mut existing = lp.iter().filter_map(|i| self.segments.get(*i)); + let Some(first) = existing.next() else { return Vec::new() }; + let forward_first = match existing.next() { Some(second) => first.b == second.a || first.b == second.b, None => true, }; @@ -102,10 +103,18 @@ impl VectorNetwork { (0..self.regions.len()).rev().find(|r| self.region_contains(*r, p)) } - /// Every point on region `r`'s loops (holes' too), each once, sorted. + /// Every point on region `r`'s loops (holes' too), each once, sorted (segments or points that + /// don't exist are left out). pub fn region_points(&self, r: usize) -> Vec { let Some(region) = self.regions.get(r) else { return Vec::new() }; - let mut out: Vec = region.loops.iter().flatten().filter_map(|i| self.segments.get(*i)).flat_map(|s| [s.a, s.b]).collect(); + let mut out: Vec = region + .loops + .iter() + .flatten() + .filter_map(|i| self.segments.get(*i)) + .flat_map(|s| [s.a, s.b]) + .filter(|v| *v < self.vertices.len()) + .collect(); out.sort_unstable(); out.dedup(); out diff --git a/crates/doc/src/vector_regions_tests.rs b/crates/doc/src/vector_regions_tests.rs index d1e3d0d8..c09664b4 100644 --- a/crates/doc/src/vector_regions_tests.rs +++ b/crates/doc/src/vector_regions_tests.rs @@ -148,6 +148,28 @@ fn a_loop_that_does_not_close_is_closed_implicitly() { assert_eq!(n.region_at(p(300.0, 100.0)), None); } +#[test] +fn missing_segments_are_skipped_wherever_they_sit_in_a_loop() { + use figcraft_geom::Shape; + let mut n = VectorNetwork::default(); + square(&mut n, 0.0, 0.0, 200.0); + n.regions = vec![region(vec![vec![0, 1, 2]], WindingRule::NonZero)]; + let control = n.region_path(0).unwrap(); + let control_walk = n.loop_walk(&[0, 1, 2]); + for lp in [vec![99, 0, 1, 2], vec![0, 99, 1, 2], vec![99, 98, 0, 1, 2]] { + n.regions = vec![region(vec![lp.clone()], WindingRule::NonZero)]; + assert_eq!(n.loop_walk(&lp), control_walk, "{lp:?}"); + assert_eq!(n.region_at(p(100.0, 100.0)), Some(0), "{lp:?}"); + assert!(n.region_contains(0, p(100.0, 100.0)), "{lp:?}"); + let path = n.region_path(0).unwrap(); + assert_eq!(path.elements().len(), control.elements().len(), "{lp:?}"); + assert!((path.area().abs() - control.area().abs()).abs() < 1e-6, "{lp:?}"); + } + n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)]; + assert!(n.region_path(0).is_none()); + assert!(!n.region_contains(0, p(100.0, 100.0))); +} + #[test] fn a_regions_points_are_listed_once() { let mut n = nested(); @@ -186,6 +208,19 @@ fn empty_and_damaged_networks_have_no_regions_and_never_panic() { } } +#[test] +fn region_points_leave_out_segment_ends_that_do_not_exist() { + let mut n = VectorNetwork::default(); + let ring = square(&mut n, 0.0, 0.0, 100.0); + n.regions = vec![region(vec![ring], WindingRule::NonZero)]; + assert_eq!(n.region_points(0), vec![0, 1, 2, 3]); + n.segments[3] = Segment::line(3, 9); + assert_eq!(n.region_points(0), vec![0, 1, 2, 3]); + assert!(n.region_path(0).is_some()); + n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)]; + assert!(n.region_points(0).is_empty()); +} + #[test] fn the_topology_key_ignores_moved_points_but_not_new_segments() { let mut n = nested(); diff --git a/crates/engine/src/al_keys_tests.rs b/crates/engine/src/al_keys_tests.rs index 1053997e..76256466 100644 --- a/crates/engine/src/al_keys_tests.rs +++ b/crates/engine/src/al_keys_tests.rs @@ -74,6 +74,27 @@ fn along_the_flow_skips_hidden_and_absolute_siblings() { assert_eq!(kids(&s, col), vec![c1, c0], "Right does nothing in a lone column"); } +#[test] +fn single_item_wrapped_lines_move_and_drop_by_line() { + let mut s = Session::new(); + // Full-width items: one per row. + let row = al(&mut s, None, "horizontal", 160.0, 200.0); + set(&mut s, row, json!({"wrap": true})); + let k: Vec = (0..2).map(|_| rect(&mut s, row, 160.0, 20.0)).collect(); + assert_eq!(s.doc().unwrap().world_bounds(k[1]).unwrap().y0, 30.0, "second row"); + let slot = crate::drop::auto_layout_slot(s.doc().unwrap(), row, figcraft_geom::Point::new(10.0, 35.0), &[]); + assert_eq!(slot, Some((1, Some([-2.0, 30.0, -2.0, 50.0])))); + arrow(&mut s, &[k[1]], 0, -1); + assert_eq!(kids(&s, row), vec![k[1], k[0]]); + + // Full-height items in a vertical wrap: one per column. + let col = al(&mut s, None, "vertical", 200.0, 160.0); + set(&mut s, col, json!({"wrap": true})); + let c: Vec = (0..3).map(|_| rect(&mut s, col, 20.0, 160.0)).collect(); + arrow(&mut s, &[c[2]], -1, 0); + assert_eq!(kids(&s, col), vec![c[0], c[2], c[1]]); +} + #[test] fn wrapped_rows_move_up_and_down_a_row() { let mut s = Session::new(); diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index a02c661c..1e8ba30f 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -73,3 +73,52 @@ fn autosaves_keep_their_flag_in_the_file() { assert!(!autosave(&mut t)); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn files_that_save_themselves_still_get_checkpoints() { + let dir = std::env::temp_dir().join(format!("figcraft-autosave-on-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("auto.figcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("pref.autoSave", &json!({"on": true})).unwrap(); + rect(&mut s, 0.0); + s.execute("file.save", &json!({"path": path})).unwrap(); + assert!(autosave(&mut s), "saved, but changed since the last version"); + assert!(!autosave(&mut s), "no change since the checkpoint"); + // Just opened: nothing to checkpoint. + let mut t = Session::empty(); + t.execute("pref.autoSave", &json!({"on": true})).unwrap(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert!(!autosave(&mut t)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_file_encoded_elsewhere_is_saved_as_of_its_snapshot() { + use crate::cmd::file::FileSnapshot; + let dir = std::env::temp_dir().join(format!("figcraft-snapshot-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("snap.figcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("file.save", &json!({"path": path})).unwrap(); + rect(&mut s, 0.0); + let file = FileSnapshot::of(s.ds().unwrap()); + let bytes = file.encode().unwrap(); + rect(&mut s, 50.0); + assert!(file.save(&mut s, &bytes).unwrap()); + assert!(s.ds().unwrap().is_dirty(), "the edit since the snapshot is still unsaved"); + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 10.0, "the file is the snapshot"); + // Saved meanwhile: the older bytes never overwrite the newer file. + let file = FileSnapshot::of(s.ds().unwrap()); + let bytes = file.encode().unwrap(); + rect(&mut s, 100.0); + s.execute("file.save", &json!({})).unwrap(); + assert!(!file.save(&mut s, &bytes).unwrap()); + assert!(!s.ds().unwrap().is_dirty()); + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 110.0); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs new file mode 100644 index 00000000..ac69118e --- /dev/null +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -0,0 +1,112 @@ +use serde_json::json; + +use crate::{EngineError, Session}; + +fn id(value: &serde_json::Value) -> crate::NodeId { + crate::params::id_of(&value["id"]).unwrap() +} + +#[test] +fn later_steps_target_layers_created_by_earlier_steps() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({ + "label": "Card", + "commands": [ + {"command": "node.create", "params": {"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}}, + {"command": "node.set", "params": {"ids": [{"$step": 1}], "props": {"name": "Swatch"}}} + ] + }), + ) + .unwrap(); + + let frame = id(&results[0]); + let rect = id(&results[1]); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(rect).unwrap().parent, Some(frame)); + assert_eq!(doc.node(rect).unwrap().name, "Swatch"); + assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step"); +} + +#[test] +fn field_picks_another_part_of_the_result() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "params": {"type": "frame", "width": 50, "height": 50}}, + {"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0, "field": "id"}}} + ]}), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().node(id(&results[1])).unwrap().parent, Some(id(&results[0]))); +} + +#[test] +fn bad_references_fail_and_roll_back() { + let mut s = Session::new(); + let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(); + let frame = json!({"command": "node.create", "params": {"type": "frame", "width": 10, "height": 10}}); + for (bad, needle) in [ + (json!({"$step": 1}), "earlier step"), + (json!({"$step": 5}), "earlier step"), + (json!({"$step": -1}), "step index"), + (json!({"$step": "0"}), "step index"), + (json!({"$step": 0, "field": "nope"}), "no `nope`"), + (json!({"$step": 0, "field": 3}), "`field` must be a string"), + (json!({"$step": 0, "extra": true}), "not `extra`"), + ] { + let r = s.execute("command.batch", &json!({"commands": [frame, {"command": "node.set", "params": {"ids": [bad], "props": {"name": "x"}}}]})); + match r { + Err(EngineError::BadParams { msg, .. }) => assert!(msg.contains(needle), "{msg} should mention {needle}"), + other => panic!("expected BadParams for {bad}, got {other:?}"), + } + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back"); + } +} + +#[test] +fn deep_params_are_refused_not_overflowed() { + let mut s = Session::new(); + let mut deep = json!(1); + for _ in 0..200 { + deep = json!([deep]); + } + let r = s.execute("command.batch", &json!({"commands": [{"command": "select.none", "params": {"x": deep}}]})); + assert!(matches!(r, Err(EngineError::BadParams { .. }))); +} + +#[test] +fn text_that_looks_like_a_reference_is_left_alone() { + let mut s = Session::new(); + let results = s + .execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": "$step 0 {\"$step\": 0}"}}]})) + .unwrap(); + let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone(); + assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == "$step 0 {\"$step\": 0}")); +} + +#[test] +fn a_nested_batch_refers_to_its_own_steps() { + let mut s = Session::new(); + let frame = |name: &str| json!({"command": "node.create", "params": {"type": "frame", "name": name, "width": 100, "height": 100}}); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + frame("Outer"), + {"command": "command.batch", "params": {"commands": [ + frame("Inner"), + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "width": 10, "height": 10}} + ]}} + ]}), + ) + .unwrap(); + let inner = &results[1]; + let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); + assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one"); +} diff --git a/crates/engine/src/branches.rs b/crates/engine/src/branches.rs index 75065b82..8bff0568 100644 --- a/crates/engine/src/branches.rs +++ b/crates/engine/src/branches.rs @@ -156,6 +156,22 @@ pub(crate) fn sync(s: &mut Session) { } } +/// The branches of the file in tab `uid` as saving it now would write them: [`sync`] only puts a +/// branch tab's latest edits in before the next command. +pub fn current(s: &Session, uid: u64) -> Vec { + let Some(ds) = s.docs.iter().find(|d| d.uid == uid) else { return Vec::new() }; + let mut branches = ds.branches.clone(); + for d in &s.docs { + let Some((_, id)) = d.branch_of.as_ref().filter(|(main, _)| *main == uid) else { continue }; + let Some(b) = branches.iter_mut().find(|b| b.id == *id) else { continue }; + b.head = d.document_itself().clone(); + if d.versions_changed { + b.versions = d.versions.clone(); + } + } + branches +} + /// The tab a save writes: the main file's for a branch tab, else the active tab. pub fn file_tab(s: &Session) -> usize { s.ds().ok().and_then(|d| d.branch_of.as_ref()).and_then(|(m, _)| s.docs.iter().position(|d| d.uid == *m)).unwrap_or(s.active) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3a0342cd..664d1ec3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -988,6 +988,16 @@ fn batch_rename(s: &mut Session, p: &Value) -> Result { Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::>()})) } +/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`. +/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets. +fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option { + if needle.is_empty() { + return None; + } + (from..hay.len()) + .find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase())))) +} + fn find_replace(s: &mut Session, p: &Value) -> Result { let find = req_string("edit.findReplace", p, "find")?; if find.is_empty() { @@ -998,11 +1008,16 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { let page = s.page()?; let doc = s.doc_mut()?; let mut hits = Vec::new(); - let lower = find.to_lowercase(); + let needle: Vec = find.chars().collect(); + let r_chars: Vec = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default(); for i in doc.subtree(page) { let Some(n) = doc.node(i) else { continue }; - let text_hit = matches!(&n.kind, NodeKind::Text(t) if t.characters.to_lowercase().contains(&lower)); - let name_hit = names && n.name.to_lowercase().contains(&lower); + let text: Option> = match &n.kind { + NodeKind::Text(t) => Some(t.characters.chars().collect()), + _ => None, + }; + let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some()); + let name_hit = names && find_ci(&n.name.chars().collect::>(), &needle, 0).is_some(); if !(text_hit || name_hit) || n.is_page() { continue; } @@ -1010,26 +1025,27 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { if let Some(r) = &replace { let n = doc.get_mut(i)?; if names && name_hit { - n.name = n.name.replace(find.as_str(), r); + let chars: Vec = n.name.chars().collect(); + let mut out = String::new(); + let mut at = 0; + while let Some(pos) = find_ci(&chars, &needle, at) { + out.extend(chars.get(at..pos).unwrap_or_default()); + out.push_str(r); + at = pos + needle.len(); + } + out.extend(chars.get(at..).unwrap_or_default()); + n.name = out; } - if let NodeKind::Text(t) = &mut n.kind { - // Replace occurrence by occurrence so styles shift correctly. - // The search resumes after each inserted replacement, so a replacement that - // contains `find` neither loops forever nor stops the other occurrences. - let mut guard = 0; + if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) { + // Replace occurrence by occurrence so styles shift correctly. The search resumes + // after each inserted replacement, so a replacement that matches `find` (in any + // case) neither loops forever nor stops the other occurrences. let mut cursor = 0; - let len = find.chars().count(); - let r_len = r.chars().count(); - loop { - let from = t.characters.char_indices().nth(cursor).map_or(t.characters.len(), |(b, _)| b); - let Some(pos) = t.characters.get(from..).and_then(|tail| tail.find(find.as_str())) else { break }; - let start = cursor + t.characters.get(from..from + pos).map_or(0, |x| x.chars().count()); - t.replace_range(start, start + len, r); - cursor = start + r_len; - guard += 1; - if guard > 10_000 { - break; - } + for _ in 0..10_000 { + let Some(start) = find_ci(&chars, &needle, cursor) else { break }; + t.replace_range(start, start + needle.len(), r); + chars.splice(start..start + needle.len(), r_chars.iter().copied()); + cursor = start + r_chars.len(); } } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8122af42..54348372 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -195,7 +195,7 @@ pub fn specs() -> Vec { spec!( "version.autosave", "Autosave version", - "{} save an automatic checkpoint when the document has unsaved changes since the last version (the app does this every 30 minutes); keeps the newest 20 autosaves → {saved, version?}", + "{} save an automatic checkpoint when the document has unsaved changes since the last version (with `pref.autoSave` on: changes made since it was opened); the app does this every 30 minutes; keeps the newest 20 autosaves → {saved, version?}", false, autosave ), @@ -257,8 +257,11 @@ pub const MAX_AUTOSAVES: usize = 20; /// `version.autosave`: a checkpoint of a document with unsaved changes since the last version. fn autosave(s: &mut Session, _: &Value) -> Result { + let auto_save = s.prefs.auto_save; let ds = s.ds_mut()?; - if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() { + // A file that saves itself is rarely unsaved: edits since it was opened count then. + let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty()); + if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() { return Ok(json!({"saved": false})); } Ok(match add_version(ds, AUTOSAVE, "", true) { @@ -693,10 +696,71 @@ pub fn thumbnail(doc: &Document) -> Option> { /// The `.figcraft` file for a document: content, thumbnail and version history. pub fn file_bytes(ds: &DocState) -> Result> { - let mut doc = (*ds.doc).clone(); - doc.current_page = Some(ds.page); - let thumb = thumbnail(&doc); - figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &ds.versions, &ds.branches).map_err(|e| EngineError::Other(e.to_string())) + FileSnapshot::of(ds).encode() +} + +/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the +/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then +/// [`save`](Self::save) the bytes back on the session's thread. +pub struct FileSnapshot { + uid: u64, + path: Option, + revision: u64, + doc: Arc, + /// The document as last saved when the snapshot was taken: another save since makes it stale. + saved: Arc, + page: NodeId, + versions: Vec, + branches: Vec, +} + +impl FileSnapshot { + pub fn of(ds: &DocState) -> Self { + Self { + uid: ds.uid, + path: ds.path.clone(), + revision: ds.revision, + doc: ds.doc.clone(), + saved: ds.saved_doc.clone(), + page: ds.page, + versions: ds.versions.clone(), + branches: ds.branches.clone(), + } + } + + /// The file's bytes: content, thumbnail and version history. + pub fn encode(&self) -> Result> { + let mut doc = (*self.doc).clone(); + doc.current_page = Some(self.page); + let thumb = thumbnail(&doc); + figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches) + .map_err(|e| EngineError::Other(e.to_string())) + } + + /// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved + /// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document + /// was closed, saved, reverted or given another path since: the bytes are out of date. + pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result { + let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else { + return Ok(false); + }; + write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?; + // The history or branches may have changed since: then they still count as unsaved. + if ds.revision == self.revision { + ds.versions_changed = false; + } + ds.saved_doc = self.doc; + ds.revision += 1; + branch_tabs_saved(s, self.uid); + Ok(true) + } +} + +/// The branch tabs of the file in tab `uid` were saved with it. +fn branch_tabs_saved(s: &mut Session, uid: u64) { + for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { + tab.mark_saved(); + } } fn color_profile(s: &mut Session, p: &Value) -> Result { @@ -721,9 +785,7 @@ fn save_to(s: &mut Session, path: String) -> Result { ds.mark_saved(); ds.revision += 1; let uid = ds.uid; - for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { - tab.mark_saved(); - } + branch_tabs_saved(s, uid); remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } @@ -762,12 +824,21 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: } /// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own -/// area), or their boxes with "include bounding box" and for slices. +/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing, +/// so they take no room next to visible ones (a hidden-only export keeps its own area). pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option { + let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible)); ids.iter() .filter_map(|i| { - let slice = doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)); - if setting.include_bounding_box || slice { doc.world_bounds(*i) } else { figcraft_render::export_bounds(doc, *i) } + let node = doc.node(*i); + let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice)); + if setting.include_bounding_box || slice { + doc.world_bounds(*i) + } else if any_visible && node.is_some_and(|n| !n.visible) { + None + } else { + figcraft_render::export_bounds(doc, *i) + } }) .reduce(|a, b| a.union(b)) } diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 8fccd607..52531aa3 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -1,5 +1,6 @@ //! Pages. +use figcraft_doc::proto::Flow; use figcraft_doc::*; use serde_json::{Value, json}; @@ -23,6 +24,15 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> { Ok(()) } +/// The page a command targets: the explicit `id` (which must be a page) or the open page. +fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result { + let Some(target) = id(p, "id") else { return s.page() }; + if !s.doc()?.pages.contains(&target) { + return Err(bad(cmd, format!("{target} is not a page"))); + } + Ok(target) +} + pub fn specs() -> Vec { vec![ spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| { @@ -71,13 +81,13 @@ pub fn specs() -> Vec { }), spec!("page.rename", "Rename page", "{id?, name} (an empty page named with a leading hyphen becomes a divider)", true, |s, p| { let name = req_string("page.rename", p, "name")?; - let id = id(p, "id").unwrap_or(s.page()?); + let id = page_target(s, "page.rename", p)?; s.doc_mut()?.get_mut(id)?.name = name; settle(s, "page.rename")?; ok() }), spec!("page.delete", "Delete page", "{id?}", true, |s, p| { - let id = id(p, "id").unwrap_or(s.page()?); + let id = page_target(s, "page.delete", p)?; s.doc_mut()?.remove(id)?; settle(s, "page.delete")?; ok() @@ -87,13 +97,23 @@ pub fn specs() -> Vec { let doc = s.doc_mut()?; let name = format!("Copy of {}", doc.get(src)?.name); let np = doc.add_page(&name); - if let NodeKind::Page { background, .. } = doc.get(src)?.kind.clone() - && let NodeKind::Page { background: b, .. } = &mut doc.get_mut(np)?.kind + // Each layer and its copy (also those in sections), so flows can follow their start frames. + let mut copies = Vec::new(); + for c in doc.get(src)?.children.clone() { + let copy = doc.duplicate_into(c, np, None)?; + copies.extend(doc.subtree(c).into_iter().zip(doc.subtree(copy))); + } + // The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides. + if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone() + && let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind { *b = background; - } - for c in doc.get(src)?.children.clone() { - doc.duplicate_into(c, np, None)?; + *f = flows + .into_iter() + .filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl })) + .collect(); + *pr = prototype; + *g = guides; } // Keep the new page right after the source. if let Some(si) = doc.pages.iter().position(|x| *x == src) { @@ -107,6 +127,9 @@ pub fn specs() -> Vec { let id = id(p, "id").unwrap_or(s.page()?); let k = req_num("page.move", p, "index")?.max(0.0) as usize; let doc = s.doc_mut()?; + if !doc.pages.contains(&id) { + return Err(bad("page.move", format!("{id} is not a page"))); + } doc.pages.retain(|x| *x != id); let k = k.min(doc.pages.len()); doc.pages.insert(k, id); @@ -114,7 +137,7 @@ pub fn specs() -> Vec { }), spec!("page.setBackground", "Page background", "{id?, color}", true, |s, p| { let c = color(p, "color").ok_or_else(|| bad("page.setBackground", "`color` required"))?; - let id = id(p, "id").unwrap_or(s.page()?); + let id = page_target(s, "page.setBackground", p)?; if let NodeKind::Page { background, .. } = &mut s.doc_mut()?.get_mut(id)?.kind { *background = c; } @@ -160,8 +183,11 @@ pub fn specs() -> Vec { } guides.remove(k); let ds = s.ds_mut()?; - if ds.guide == Some((owner, k)) { - ds.guide = None; + match ds.guide { + Some((o, i)) if o == owner && i == k => ds.guide = None, + // A later guide of the same owner moves up one place; keep the selection on it. + Some((o, i)) if o == owner && i > k => ds.guide = Some((o, i - 1)), + _ => {} } ok() }), diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index a5f2b78f..552a44cf 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -112,6 +112,9 @@ pub fn specs() -> Vec { spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| { let name = req_string("variable.createCollection", p, "name")?; let doc = s.doc_mut()?; + if doc.variables.collections.iter().any(|c| c.name == name) { + return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists"))); + } let id = doc.new_collection_id(); let modes: Vec = match get(p, "modes") { Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(), diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index ae627abe..3e6dc69d 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -87,7 +87,7 @@ fn update(s: &mut Session, p: &Value) -> Result { } None => None, }; - let scopes = match get(p, "scopes") { + let scopes = match p.get("scopes") { None => None, Some(Value::Null) => Some(None), Some(Value::Array(a)) => { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 6135e5df..de69e690 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -138,8 +138,16 @@ fn page_step(s: &mut Session, dir: i64) -> Result { let pages = &ds.doc.pages; let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64; let n = pages.len() as i64; - let next = (i + dir).clamp(0, n - 1) as usize; - let Some(pg) = pages.get(next).copied() else { return ok() }; + // Dividers never open: step over them, and stay put when only dividers lie ahead. + let mut next = i + dir; + let pg = loop { + let page = usize::try_from(next).ok().and_then(|k| pages.get(k).copied()); + match page { + Some(p) if !ds.doc.is_divider(p) => break p, + Some(_) if dir != 0 && next >= 0 && next < n => next += dir, + _ => break ds.page, + } + }; let ds = s.ds_mut()?; ds.page = pg; ds.selection.clear(); @@ -334,6 +342,7 @@ pub fn specs() -> Vec { spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)), spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), + spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)), spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| { s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0); s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0); @@ -369,18 +378,24 @@ pub fn specs() -> Vec { Ok(json!({"url": s.prefs.publish_server, "default": crate::live::DEFAULT_SERVER})) } ), - spec!("pref.language", "Interface language", "{language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", false, |s, p| { - let lang = req_string("pref.language", p, "language")?; - let lang = lang.trim(); - // A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system". - let tag = lang.len() <= 35 - && lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric())); - if !tag { - return Err(bad("pref.language", format!("`{lang}` is not a language code"))); + spec!( + "pref.language", + "Interface language", + "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", + false, + |s, p| { + let lang = req_string("pref.language", p, "language")?; + let lang = lang.trim(); + // A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system". + let tag = lang.len() <= 35 + && lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric())); + if !tag { + return Err(bad("pref.language", format!("`{lang}` is not a language code"))); + } + s.prefs.language = lang.to_string(); + Ok(json!({"language": s.prefs.language})) } - s.prefs.language = lang.to_string(); - Ok(json!({"language": s.prefs.language})) - }), + ), spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")), spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")), spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")), diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 513bf509..7d35229f 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -113,8 +113,58 @@ pub(crate) fn num(v: f64) -> String { } /// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end -/// in "px" are left alone. +/// in "px", markup lines (layer text), string literals and comments are left alone. pub fn px_to_rem(code: &str, root: f64) -> String { + let mut out = String::with_capacity(code.len()); + let mut in_comment = false; + for line in code.split_inclusive('\n') { + if !in_comment && line.trim_start().starts_with('<') { + out.push_str(line); + continue; + } + // Lengths outside strings and comments convert; `plain` is where that stretch starts. + let mut plain = 0; + let mut chars = line.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + if in_comment { + if c == '*' && chars.peek().is_some_and(|(_, n)| *n == '/') { + chars.next(); + out.push_str(line.get(plain..i + 2).unwrap_or("")); + plain = i + 2; + in_comment = false; + } + } else if c == '/' && chars.peek().is_some_and(|(_, n)| *n == '*') { + chars.next(); + out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root)); + plain = i; + in_comment = true; + } else if c == '"' { + out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root)); + let mut end = line.len(); + while let Some((j, s)) = chars.next() { + if s == '\\' { + chars.next(); + } else if s == '"' { + end = j + 1; + break; + } + } + out.push_str(line.get(i..end).unwrap_or("")); + plain = end; + } + } + let rest = line.get(plain..).unwrap_or(""); + if in_comment { + out.push_str(rest); + } else { + out.push_str(&lengths_to_rem(rest, root)); + } + } + out +} + +/// `code` with every pixel length in rem of `root` pixels; names that end in "px" are left alone. +fn lengths_to_rem(code: &str, root: f64) -> String { let ident = |c: char| c.is_alphanumeric() || c == '_'; let mut out = String::with_capacity(code.len()); let mut rest = code; diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index c810cf1f..1416b15f 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -615,10 +615,10 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String { } } -/// `data-*` attribute names for variant parameters. -fn data_attr(p: &Param) -> String { +/// `data-*` attribute names for variant parameters, unique among them (in order). +fn data_attrs(variants: &[&Param]) -> Vec { let mut used = Vec::new(); - format!("data-{}", class_name(&p.prop, &mut used)) + variants.iter().map(|p| format!("data-{}", class_name(&p.prop, &mut used))).collect() } /// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom @@ -628,6 +628,7 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default(); let root_class = class_name(&root_name, &mut Vec::new()); let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect(); + let attr_names = data_attrs(&variants); let pass = |root: NodeId| { let vctx = Ctx { doc, root, comp: Some(c), base: 2 }; let mut m = Markup::new(&vctx, lang); @@ -639,9 +640,10 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String m.root_class = Some(root_class.clone()); m.root_attrs = variants .iter() - .map(|p| match lang { - CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)), - _ => format!(" {}={{{}}}", data_attr(p), p.ident), + .zip(&attr_names) + .map(|(p, attr)| match lang { + CodeLang::Leptos => format!(" {attr}={{{}.as_str()}}", rust_ident(&p.ident)), + _ => format!(" {attr}={{{}}}", p.ident), }) .collect(); m.node(c.comp, None, 2, ""); @@ -653,7 +655,8 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String let pairs = variant_props(&vn.name); let attrs: String = variants .iter() - .filter_map(|p| pairs.get(&p.prop).map(|val| format!("[{}=\"{}\"]", data_attr(p), val.replace(['"', '\\'], "")))) + .zip(&attr_names) + .filter_map(|(p, attr)| pairs.get(&p.prop).map(|val| format!("[{attr}=\"{}\"]", val.replace(['"', '\\'], "")))) .collect(); for (path, _, decls) in pass(v) { let Some((_, class, base)) = m.rules.iter().find(|(p, _, _)| *p == path) else { continue }; diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index c92815a7..4cd3d776 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -314,3 +314,32 @@ fn bound_variables_become_custom_properties() { let react = code(&mut s, c, "react"); assert!(react.contains(":root {\n --colors-brand-500: #3366FF;\n}"), "{react}"); } + +#[test] +fn case_colliding_variant_properties_get_distinct_data_attributes() { + let mut s = Session::new(); + let mut variants = Vec::new(); + for (k, (first, second, fill)) in [("Small", "Big", "#0000FF"), ("Large", "Small", "#FF0000")].iter().enumerate() { + let name = format!("Size={first}, size={second}"); + let f = idv(&ex( + &mut s, + "frame.create", + json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": name, "props": {"fill": fill}}), + )); + variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]})))); + } + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants}))); + ex(&mut s, "node.set", json!({"id": set, "props": {"name": "Chip"}})); + for lang in ["react", "leptos"] { + let v = component_code(&mut s, set, lang); + let def = v["definition"].as_str().unwrap_or_default(); + let root = def.lines().find(|l| l.trim_start().starts_with("
= + root.split_whitespace().filter_map(|w| w.strip_prefix("data-")).map(|w| w.split('=').next().unwrap_or_default()).collect(); + assert_eq!(attrs.len(), 2, "{lang}: {root}"); + assert_ne!(attrs[0], attrs[1], "{lang}: distinct attributes in {root}"); + let sel = format!(".chip[data-{}=\"Large\"][data-{}=\"Small\"]", attrs[0], attrs[1]); + assert!(def.contains(&sel), "{lang}: {sel} in:\n{def}"); + assert!(def.contains("#FF0000"), "{lang}:\n{def}"); + } +} diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 0975f3fa..05a95f66 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -219,6 +219,24 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() { assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the move was undone"); } +#[test] +fn a_pen_click_that_wobbles_a_pixel_is_still_a_click() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + // A real click: the mouse moves a pixel or two between press and release. + s.pointer(&ev(PointerKind::Down, (100.0, 100.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Move, (102.0, 101.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Up, (102.0, 101.0), NONE)).unwrap(); + let out = s.tool.pen.as_ref().and_then(|p| p.points.first()).map(|(_, _, o)| *o); + assert_eq!(out.map(|o| o.hypot()), Some(0.0), "no handle pulled out"); + assert!(s.tool.overlay.pen_handles.is_empty()); + // Past the slop it is a drag: the handle follows the pointer from where it was pressed. + s.pointer(&ev(PointerKind::Down, (200.0, 100.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Move, (204.0, 100.0), NONE)).unwrap(); + let out = s.tool.pen.as_ref().and_then(|p| p.points.last()).map(|(_, _, o)| (o.x, o.y)); + assert_eq!(out, Some((4.0, 0.0))); +} + #[test] fn the_pen_shows_what_a_click_would_do() { // The Pen tool drawing a new path: three points, then over each of them. diff --git a/crates/engine/src/devfocus_tests.rs b/crates/engine/src/devfocus_tests.rs index 5583efe9..1607ce56 100644 --- a/crates/engine/src/devfocus_tests.rs +++ b/crates/engine/src/devfocus_tests.rs @@ -93,3 +93,29 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() { click(&mut s, 350.0, 100.0); assert_eq!(s.selection(), vec![b], "everything is hit again"); } + +#[test] +fn nested_frames_stay_out_of_the_ready_list_and_its_navigation() { + let mut s = Session::new(); + let screen = frame(&mut s, 0.0, "Screen"); + let card = idv(&ex(&mut s, "frame.create", json!({"parent": screen, "width": 100, "height": 50, "name": "Card"}))); + // A status a nested frame already stores (an older file, or set through node.set) is ignored. + ex(&mut s, "node.set", json!({"id": card, "props": {"devStatus": "readyForDev"}})); + assert_eq!(ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))["items"], json!([])); + assert!(s.execute("dev.focus", &json!({"direction": "next"})).is_err(), "nothing is ready"); + ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}})); + let ids = |v: serde_json::Value| -> Vec { + v["items"].as_array().map(|a| a.iter().map(|i| i["id"].clone()).collect()).unwrap_or_default() + }; + assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen)]); + ex(&mut s, "dev.focus", json!({"id": screen})); + for direction in ["next", "previous"] { + let v = ex(&mut s, "dev.focus", json!({"direction": direction})); + assert_eq!((v["id"].clone(), v["count"].clone()), (json!(screen), json!(1)), "{direction} wraps to the only ready frame"); + } + // A frame directly inside a section still counts. + let sec = idv(&ex(&mut s, "node.create", json!({"type": "section", "x": 1000, "y": 0, "width": 400, "height": 300}))); + let in_sec = idv(&ex(&mut s, "frame.create", json!({"parent": sec, "width": 100, "height": 100, "name": "In section"}))); + ex(&mut s, "node.set", json!({"ids": [sec, in_sec], "props": {"devStatus": "readyForDev"}})); + assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen), json!(sec), json!(in_sec)]); +} diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index e94e88b5..fe400989 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -231,7 +231,7 @@ pub fn has_status(doc: &Document, id: NodeId) -> bool { } /// The layers with handoff status `status` (`None`: any status) on `page` (`None`: every page), -/// in page and layer order (bounded). +/// in page and layer order (bounded). Only layers that can carry a status are listed. pub fn dev_items(doc: &Document, status: Option, page: Option) -> Vec { let mut out = Vec::new(); let mut visited = 0usize; @@ -243,7 +243,7 @@ pub fn dev_items(doc: &Document, status: Option, page: Option return out; } let Some(n) = doc.node(id) else { continue }; - if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) { + if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) { out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status }); } stack.extend(n.children.iter().rev().copied()); diff --git a/crates/engine/src/double_click_tests.rs b/crates/engine/src/double_click_tests.rs index 2ffa0083..83ea82fd 100644 --- a/crates/engine/src/double_click_tests.rs +++ b/crates/engine/src/double_click_tests.rs @@ -77,6 +77,26 @@ fn double_clicks_reach_and_edit_text_in_a_group_wherever_they_land() { } } +#[test] +fn editing_text_keeps_other_document_changes_since_open() { + let mut s = Session::new(); + let first = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 80, "height": 40}))["id"]).unwrap(); + let deleted = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 120, "y": 20, "width": 80, "height": 40}))["id"]).unwrap(); + let text = id_of(&ex(&mut s, "text.create", json!({"text": "A", "x": 20, "y": 100}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [first], "props": {"x": 650}})); + ex(&mut s, "edit.delete", json!({"ids": [deleted]})); + + let bounds = s.doc().unwrap().world_bounds(text).unwrap(); + double_click(&mut s, bounds.center().x, bounds.center().y, 0.0, 1.0); + assert_eq!(editing(&s), Some(text)); + crate::textedit::insert(&mut s, "B").unwrap(); + crate::textedit::end(&mut s).unwrap(); + + assert_eq!(s.doc().unwrap().world_bounds(first).unwrap().x0, 650.0); + assert!(!s.doc().unwrap().contains(deleted)); + assert_eq!(s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters, "B"); +} + #[test] fn a_press_drags_only_past_the_threshold_in_screen_pixels() { for (zoom, by, moves) in [(1.0, 2.0, false), (1.0, 4.0, true), (4.0, 0.5, false), (4.0, 1.0, true), (0.25, 8.0, false), (0.25, 16.0, true)] { diff --git a/crates/engine/src/drop.rs b/crates/engine/src/drop.rs index f478735e..d5e8b3cf 100644 --- a/crates/engine/src/drop.rs +++ b/crates/engine/src/drop.rs @@ -61,12 +61,15 @@ pub fn flip(r: figcraft_geom::Rect, vertical: bool) -> figcraft_geom::Rect { /// The visual rows (columns when `vertical`) of a wrapping layout's placed children `kids`, each /// child as (its index in `kids`, its page bounds [`flip`]ped so the flow runs along x). Children -/// wrap in flow order, so a new row starts where a child sits before the one before. +/// wrap in flow order, so a new row starts where a child sits before the one before, or where it +/// does not follow the one before along the flow but sits past it across (one child per line). pub fn wrap_rows(doc: &Document, kids: &[NodeId], vertical: bool) -> Vec> { let mut rows: Vec> = Vec::new(); for (i, b) in kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, flip(b, vertical)))) { match rows.last_mut() { - Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5) => row.push((i, b)), + Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5 && !(b.x0 < prev.x1 - 0.5 && b.y0 >= prev.y1 - 0.5)) => { + row.push((i, b)) + } _ => rows.push(vec![(i, b)]), } } diff --git a/crates/engine/src/html_codegen_tests.rs b/crates/engine/src/html_codegen_tests.rs index 95b8f40d..b7fbb9ac 100644 --- a/crates/engine/src/html_codegen_tests.rs +++ b/crates/engine/src/html_codegen_tests.rs @@ -77,3 +77,43 @@ fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() { let out = s.execute("inspect.code", &json!({"id": card, "language": "leptos", "unit": "rem"})).unwrap(); assert!(out["code"].as_str().unwrap().contains("width: 12.5rem;"), "{}", out["code"]); } + +fn web_code(s: &mut Session, id: figcraft_doc::NodeId, language: &str, unit: &str) -> String { + let out = s.execute("inspect.code", &json!({"id": id, "language": language, "unit": unit})).unwrap(); + out["code"].as_str().unwrap().to_string() +} + +#[test] +fn rem_preserves_text_in_web_markup() { + let mut s = Session::new(); + let frame = id(s.execute("frame.create", &json!({"width": 200, "height": 100, "name": "Card"})).unwrap()); + s.execute("text.create", &json!({"text": "Tile 24px", "parent": frame, "name": "Label"})).unwrap(); + let mut changed = Vec::new(); + for language in ["html", "react", "leptos"] { + let px = web_code(&mut s, frame, language, "px"); + let rem = web_code(&mut s, frame, language, "rem"); + assert!(px.contains("Tile 24px"), "{language}: px control: {px}"); + assert!(rem.contains("width: 12.5rem;"), "{language}: width: {rem}"); + assert!(rem.contains("font-size: 0.75rem;"), "{language}: font: {rem}"); + if !rem.contains("Tile 24px") { + changed.push(language); + } + } + assert!(changed.is_empty(), "text rewritten in {changed:?}"); +} + +#[test] +fn rem_preserves_text_property_default() { + let mut s = Session::new(); + let frame = id(s.execute("frame.create", &json!({"width": 100, "height": 40, "name": "Chip"})).unwrap()); + let label = id(s.execute("text.create", &json!({"text": "24px", "parent": frame, "name": "Label"})).unwrap()); + let component = id(s.execute("component.create", &json!({"ids": [frame]})).unwrap()); + s.execute("component.addProperty", &json!({"id": component, "name": "Label", "type": "text", "default": "24px", "layer": label})).unwrap(); + for unit in ["px", "rem"] { + let code = web_code(&mut s, component, "react", unit); + assert!(code.contains("label = \"24px\""), "{unit}: changed default: {code}"); + if unit == "rem" { + assert!(code.contains("width: 6.25rem;"), "rem still converts lengths: {code}"); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 04090a3b..560ff56a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -108,6 +108,47 @@ pub fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() } } +/// Deepest params nesting `resolve_step_refs` walks before giving up. +const STEP_REF_MAX_DEPTH: usize = 64; + +/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default +/// `id`) of `results[n]`, the result of an earlier batch step. +fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result { + if depth > STEP_REF_MAX_DEPTH { + return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels"))); + } + match v { + Value::Object(o) if o.contains_key("$step") => { + if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") { + return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`"))); + } + let n = o + .get("$step") + .and_then(Value::as_u64) + .ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?; + let field = match o.get("field") { + None => "id", + Some(Value::String(f)) => f.as_str(), + Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))), + }; + let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else { + let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) }; + return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})"))); + }; + result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`"))) + } + Value::Object(o) => { + let mut out = serde_json::Map::with_capacity(o.len()); + for (k, x) in o { + out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?); + } + Ok(Value::Object(out)) + } + Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::>>().map(Value::Array), + _ => Ok(v.clone()), + } +} + /// One undo step. #[derive(Clone, Debug)] pub struct HistoryEntry { @@ -574,6 +615,9 @@ pub struct Prefs { /// Reduce motion: the interface's own animations are off (the system's setting turns them off /// too). Prototype transitions still play. pub reduce_motion: bool, + /// Files that have a path are saved by themselves a few seconds after they change (the app's + /// timer; off: only File ▸ Save writes them). + pub auto_save: bool, /// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed /// builds, off for development builds. pub invite_links: Option, @@ -626,6 +670,7 @@ impl Default for Prefs { language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, + auto_save: false, invite_links: None, plugins: Vec::new(), plugin_settings: BTreeMap::new(), @@ -1082,9 +1127,20 @@ impl Session { self.ui_requests.push(UiRequest { kind: kind.to_string(), data }); } - /// Run several commands as one undo step. + /// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id` + /// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field), + /// so a step can target a layer that an earlier step of the same batch created. Any object + /// with a `$step` key in a step's params is such a reference, except inside a nested + /// `command.batch` step: its references are to its own steps. pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result> { - self.grouped(label, |s| cmds.iter().map(|(id, p)| s.execute(id, p)).collect()) + self.grouped(label, |s| { + let mut results: Vec = Vec::with_capacity(cmds.len()); + for (step, (id, p)) in cmds.iter().enumerate() { + let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? }; + results.push(s.execute(id, &p)?); + } + Ok(results) + }) } /// Run `f` as one undo step called `label`: the commands it executes push no steps of their @@ -1181,8 +1237,8 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec { pub fn normalize(doc: &mut Document, before: &Document) { // Only what was touched since the last normalisation (edited, added or removed). let mut touched: BTreeSet = doc.take_dirty(); - // A variable edit changes no layer directly, but bound layers must follow it. - if touched.is_empty() && doc.variables == before.variables { + // A variable or style edit changes no layer directly, but bound and linked layers must follow it. + if touched.is_empty() && doc.variables == before.variables && doc.styles == before.styles { return; } let m = measure::TextMeasure; @@ -1407,6 +1463,8 @@ mod autosave_tests; #[cfg(test)] mod batch_rollback_tests; #[cfg(test)] +mod batch_step_ref_tests; +#[cfg(test)] mod binding_lists_tests; #[cfg(test)] mod booleans_tests; @@ -1501,6 +1559,8 @@ mod invite_links_tests; #[cfg(test)] mod keymap_tests; #[cfg(test)] +mod library_range_styles_tests; +#[cfg(test)] mod library_variables_tests; #[cfg(test)] mod live_access_tests; @@ -1529,6 +1589,8 @@ mod open_issues_tests; #[cfg(test)] mod outline_options_tests; #[cfg(test)] +mod page_target_tests; +#[cfg(test)] mod paint_reorder_tests; #[cfg(test)] mod paint_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 936d798a..73c599a4 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -134,9 +134,12 @@ pub fn closure(doc: &Document, roots: &[NodeId]) -> Vec { out } -/// The style ids a layer links to (fill, stroke, effect, grid, text). +/// The style ids a layer links to (fill, stroke, effect, grid, text, and text styles on +/// character ranges). pub(crate) fn style_links(n: &Node) -> impl Iterator { - [&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten() + let chars = + n.kind.text().into_iter().flat_map(|t| std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style))).filter_map(|s| s.style_id.as_ref()); + [&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten().chain(chars) } /// Style ids the layers under `roots` link to. @@ -582,11 +585,15 @@ fn local_key(lid: &str, key: &str, taken: impl Fn(&str) -> bool) -> String { } /// Copy library styles `keys` into `doc` as linked remote styles (with `update`, copies older than -/// the library's are refreshed). Returns library style id → local id. +/// the library's are refreshed). The variables a written copy binds are imported too and its +/// bindings point at their copies (never at this file's variables of the same ids). Returns library +/// style id → local id. pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet, update: bool) -> BTreeMap { let lid = lib.manifest.id.clone(); let mut out = BTreeMap::new(); let mut hashes = BTreeSet::new(); + // Local ids of the copies written now, with the library style they copy. + let mut written: Vec<(String, &Style)> = Vec::new(); for key in keys { let Some(style) = lib.doc.styles.iter().find(|s| s.id == *key) else { continue }; let version = lib.manifest.style(key).map_or(lib.manifest.version, |s| s.version); @@ -598,6 +605,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet { *slot = Style { id: r.local.clone(), ..style.clone() }; set_record(&mut doc.libraries_mut().styles, ImportedStyle { version, ..r.clone() }); + written.push((r.local.clone(), style)); } r.local } @@ -605,6 +613,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet let local = local_key(&lid, key, |l| doc.styles.iter().any(|s| s.id == l)); doc.styles.push(Style { id: local.clone(), ..style.clone() }); set_record(&mut doc.libraries_mut().styles, ImportedStyle { library: lid.clone(), key: key.clone(), local: local.clone(), version }); + written.push((local.clone(), style)); local } }; @@ -613,6 +622,17 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet } out.insert(key.clone(), local); } + let bound: BTreeSet = written.iter().flat_map(|(_, s)| s.bound.values().cloned()).collect(); + if !bound.is_empty() { + let vars = import_variables(doc, lib, &bound, &BTreeSet::new(), update); + for (local, _) in &written { + if let Some(slot) = doc.styles.iter_mut().find(|s| s.id == *local) { + // Bindings to variables the library lacks are dropped (the published value stays). + slot.bound = + std::mem::take(&mut slot.bound).into_iter().filter_map(|(f, v)| vars.variables.get(&v).map(|l| (f, l.clone()))).collect(); + } + } + } copy_images(doc, &lib.doc, &hashes); out } diff --git a/crates/engine/src/library_range_styles_tests.rs b/crates/engine/src/library_range_styles_tests.rs new file mode 100644 index 00000000..077470e7 --- /dev/null +++ b/crates/engine/src/library_range_styles_tests.rs @@ -0,0 +1,66 @@ +//! Text styles linked on character ranges travel with components (publish and place). + +use crate::Session; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")) +} + +fn node(value: Value) -> NodeId { + NodeId(value["id"].as_u64().unwrap()) +} + +/// A component with "Hello" (chars 0..2 linked to a range style) and "World" (whole-layer style). +fn fixture() -> (Session, NodeId, String, String) { + let mut s = Session::new(); + let frame = node(run(&mut s, "frame.create", json!({"name":"Card", "width":100, "height":40}))); + let text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"Hello"}))); + let range = run(&mut s, "style.create", json!({"type":"text", "name":"Emphasis", "value":{"fontSize":32}}))["id"].as_str().unwrap().to_owned(); + run(&mut s, "style.apply", json!({"id":text, "style":range, "start":0, "end":2})); + let whole_text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"World"}))); + let whole = run(&mut s, "style.create", json!({"type":"text", "name":"Whole", "value":{"fontSize":24}}))["id"].as_str().unwrap().to_owned(); + run(&mut s, "style.apply", json!({"id":whole_text, "style":whole})); + let component = node(run(&mut s, "component.create", json!({"ids":[frame]}))); + let source = s.doc().unwrap().node(text).unwrap(); + let chars = source.kind.text().unwrap(); + assert!(source.styles.text.is_none()); + assert_eq!(chars.style_at(0).style_id.as_deref(), Some(range.as_str())); + assert_eq!(chars.style_at(2).style_id, None); + (s, component, range, whole) +} + +#[test] +fn component_publish_keeps_range_text_style() { + let (mut s, component, range, whole) = fixture(); + run(&mut s, "library.publish", json!({"include":[component]})); + let snapshot = &s.doc().unwrap().published.as_ref().unwrap().doc; + assert!(snapshot.style(&whole).is_some()); + assert!(snapshot.style(&range).is_some(), "character-range style missing from snapshot"); +} + +#[test] +fn component_place_imports_range_text_style() { + let (mut source, component, range, whole) = fixture(); + let publication = run(&mut source, "library.publish", json!({})); + let library = source.doc().unwrap().published.as_ref().unwrap().clone(); + assert!(library.doc.style(&range).is_some()); + let mut target = Session::new(); + let path = "figcraft://library.test/range-styles"; + target.libraries.insert(path, Ok(library)); + run(&mut target, "library.add", json!({"path":path})); + let instance = node(run(&mut target, "library.place", json!({"library":publication["id"], "component":component}))); + let doc = target.doc().unwrap(); + let children = &doc.node(instance).unwrap().children; + let ranged = doc.node(children[0]).unwrap().kind.text().unwrap(); + let full = doc.node(children[1]).unwrap().kind.text().unwrap(); + let whole_record = doc.libraries.styles.iter().find(|r| r.key == whole).unwrap(); + assert_eq!(full.style_at(0).style_id.as_deref(), Some(whole_record.local.as_str())); + assert_eq!(ranged.style_at(0).font_size, 32.0); + assert!(ranged.style_at(2).style_id.is_none()); + let record = doc.libraries.styles.iter().find(|r| r.key == range); + assert!(record.is_some(), "character-range style missing after placement"); + assert_eq!(ranged.style_at(0).style_id.as_deref(), record.map(|r| r.local.as_str())); + assert!(doc.style(ranged.style_at(0).style_id.as_deref().unwrap()).is_some()); +} diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index 4bbaad57..4fc778a7 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -314,3 +314,33 @@ fn library_variable_commands_fail_cleanly() { } assert!(s.doc().unwrap().libraries.is_empty()); } + +#[test] +fn library_style_bindings_ignore_unrelated_local_variables() { + let d = dir("style-bound"); + let path = path_str(&d.join("lib.figcraft")); + let mut lib = Session::new(); + ex(&mut lib, "variable.createCollection", json!({"name": "Tokens"})); + let var = ex(&mut lib, "variable.create", json!({"collection": "Tokens", "name": "Brand", "type": "color", "value": "#0000ff"}))["id"].clone(); + let style = ex(&mut lib, "style.create", json!({"type": "paint", "name": "Brand", "value": "#0000ff"}))["id"].clone(); + ex(&mut lib, "style.bind", json!({"style": style, "field": "fill", "variable": var})); + let lid = publish_to(&mut lib, &path); + + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Local"})); + let local = ex(&mut s, "variable.create", json!({"collection": "Local", "name": "Unrelated", "type": "color", "value": "#ff0000"}))["id"].clone(); + assert_eq!(local, var, "the ids must collide for this test"); + let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 40, "height": 40}))); + ex(&mut s, "library.add", json!({"path": path})); + ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "id": r})); + assert_eq!(fill(&s, r), "0000FF"); + healthy(&s); + + // An updated style follows the library's variable, not the local one. + ex(&mut lib, "variable.set", json!({"variable": "Brand", "value": "#00ffff"})); + republish(&mut lib); + ex(&mut s, "library.acceptUpdates", json!({})); + assert_eq!(fill(&s, r), "00FFFF"); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index 09c5831d..4001b156 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -517,6 +517,11 @@ pub trait LiveLink: Send { fn background(&mut self, _job: u64, _req: NetRequest) -> bool { false } + /// Remove client `client` from session `session`, which this link hosts: they are + /// disconnected and can't join again from their address while it runs. Returns at once. + fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> { + Err("removing people needs the desktop app or the MCP server".into()) + } /// End session `session` (stop hosting or leave); the others go on. Nothing for a session /// that is over already. fn leave(&mut self, session: SessionId); @@ -1889,6 +1894,59 @@ fn follow(s: &mut Session, p: &Value) -> Result { Ok(json!({"follow": s.live().follow})) } +/// Remove someone from the open file's session: in a session this app hosts, the hub drops them; +/// in a file of yours on a server, the server does (with the file's owner token). They are told +/// the session is over for them, keep their copy, and can't join again from their address while +/// the session (or the server) runs. +fn remove_person(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "collab.removePerson"; + let client = + get(p, "client").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(CMD, "`client` must be a client id"))?; + let live = s.live(); + if live.status != Status::Live { + return Err(bad(CMD, "the open file isn't in a live session")); + } + let name = + live.peers.get(&client).map(|p| p.name.clone()).ok_or_else(|| bad(CMD, format!("nobody with client id {client} is in the session")))?; + let (id, role, server_file) = (live.id, live.role(), live.server_file()); + match (role, server_file) { + (Some(Role::Host), _) => { + let link = s.live.link.as_deref_mut().ok_or_else(|| unavailable(CMD))?; + link.remove(id, client).map_err(EngineError::Other)?; + } + (Some(Role::Guest), Some((server, file))) => { + let owner = + s.live().published.as_ref().and_then(|p| p.owner.clone()).or_else(|| s.online_owner(&server, &file)).filter(|o| !o.is_empty()); + let owner = owner.ok_or_else(|| bad(CMD, "only the file's owner can remove people from it"))?; + let req = Some(Admin::RemovePeer { file: file.clone(), client }); + match hosting_request(s, CMD, &server, &file, &owner, req) { + Ok((_, Some(AdminReply::Done))) => {} + Ok(_) => return Err(EngineError::Other(format!("couldn't remove {name}: the server answered something else"))), + // A server older than this request can't read it. + Err(e) => return Err(EngineError::Other(format!("couldn't remove {name}: {e} (the server may be older than removing people)"))), + } + } + _ => return Err(bad(CMD, "only the person hosting the session, or the file's owner, can remove people")), + } + s.message = Some(format!("{name} was removed from the session")); + Ok(json!({"removed": client, "name": name})) +} + +/// You may remove others from the open file's session: you host it, or it is your file on a +/// server. +pub fn can_remove_people(s: &Session) -> bool { + let live = s.live(); + live.status == Status::Live + && match live.role() { + Some(Role::Host) => true, + Some(Role::Guest) => live.server_file().is_some_and(|(server, file)| { + live.published.as_ref().and_then(|p| p.owner.as_deref()).is_some_and(|o| !o.is_empty()) + || s.online_owner(&server, &file).is_some_and(|o| !o.is_empty()) + }), + None => false, + } +} + fn cursor(s: &mut Session, p: &Value) -> Result { let at = match (num(p, "x"), num(p, "y")) { (Some(x), Some(y)) => Some((x, y)), @@ -1997,6 +2055,13 @@ pub fn specs() -> Vec { |s, _| Ok(s.live_summary()) ), spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow), + spec!( + "collab.removePerson", + "Remove from session", + "{client (a peer's id)} → {removed, name}: they are disconnected (they keep their copy) and can't join again from their address while the session runs; only the person hosting, or the owner of a file on a server, can do it", + false, + remove_person + ), spec!( "collab.cursor", "Show cursor at", diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index 6b05199b..cacc4f37 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -28,6 +28,8 @@ pub(crate) struct Script { /// The session each presence in `sent` and each message in `msgs` was for. pub(crate) sent_to: Vec, pub(crate) msgs_to: Vec, + /// The people removed: `(session, client)`. + pub(crate) removed: Vec<(u64, u32)>, } pub(crate) struct Mock(pub(crate) Arc>); @@ -62,6 +64,10 @@ impl LiveLink for Mock { s.published.push(opts); Ok(()) } + fn remove(&mut self, session: u64, client: u32) -> Result<(), String> { + self.0.lock().unwrap().removed.push((session, client)); + Ok(()) + } fn leave(&mut self, session: u64) { let mut s = self.0.lock().unwrap(); s.left += 1; @@ -314,6 +320,43 @@ fn the_host_ending_resets_everything() { assert_eq!(s.message.as_deref(), Some("The host ended the session")); } +#[test] +fn the_host_removes_people_and_guests_cant() { + let (mut s, script) = session(); + let remove = |s: &mut Session, client: serde_json::Value| s.execute("collab.removePerson", &json!({ "client": client })); + // Not in a session: nobody to remove. + assert!(matches!(remove(&mut s, json!(2)), Err(EngineError::BadParams { .. }))); + assert!(!crate::live::can_remove_people(&s)); + s.execute("collab.host", &json!({})).unwrap(); + let id = s.live().id; + push(&script, vec![Event::PeerJoined(peer(2, "Ada")), Event::PeerJoined(peer(3, "Bo"))]); + s.live_poll(); + assert!(crate::live::can_remove_people(&s)); + // Only someone who is here, by a client id. + for bad in [json!(9), json!(1), json!(-2), json!("2"), json!(4_294_967_296u64)] { + assert!(matches!(remove(&mut s, bad.clone()), Err(EngineError::BadParams { .. })), "{bad}"); + } + let r = remove(&mut s, json!(2)).unwrap(); + assert_eq!((r["removed"].as_u64(), r["name"].as_str()), (Some(2), Some("Ada"))); + assert_eq!(script.lock().unwrap().removed, [(id, 2)]); + assert_eq!(s.message.as_deref(), Some("Ada was removed from the session")); + // The host's own session tells them she left. + push(&script, vec![Event::PeerLeft(2)]); + s.live_poll(); + assert_eq!(s.live().peers.keys().copied().collect::>(), [3]); + s.execute("collab.stop", &json!({})).unwrap(); + + // A guest in someone else's session can't remove anyone. + let (mut s, script) = session(); + s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); + push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana"), peer(3, "Bo")] }]); + s.live_poll(); + assert!(!crate::live::can_remove_people(&s)); + let e = remove(&mut s, json!(3)).unwrap_err().to_string(); + assert!(e.contains("only the person hosting"), "{e}"); + assert!(script.lock().unwrap().removed.is_empty()); +} + #[test] fn collab_commands_survive_junk() { let junk = [ diff --git a/crates/engine/src/page_target_tests.rs b/crates/engine/src/page_target_tests.rs new file mode 100644 index 00000000..fd88685d --- /dev/null +++ b/crates/engine/src/page_target_tests.rs @@ -0,0 +1,63 @@ +//! Page commands act on pages: a layer id (a rectangle, a frame) is refused before anything about +//! the layer changes, as `page.select` already does. + +use figcraft_doc::{NodeId, NodeKind}; +use serde_json::{Value, json}; + +use crate::Session; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn name(s: &Session, id: NodeId) -> String { + s.doc().unwrap().get(id).unwrap().name.clone() +} + +#[test] +fn page_commands_reject_a_rectangle() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))); + let before = name(&s, r); + assert!(s.execute("page.select", &json!({"id": r})).is_err()); + assert!(s.execute("page.rename", &json!({"id": r, "name": "Not a page"})).is_err(), "rename refuses a layer"); + assert_eq!(name(&s, r), before, "the layer keeps its name"); + assert!(s.execute("page.setBackground", &json!({"id": r, "color": "#123456"})).is_err(), "background refuses a layer"); + assert!(s.execute("page.delete", &json!({"id": r})).is_err(), "delete refuses a layer"); + assert!(s.doc().unwrap().contains(r), "the layer is still there"); + assert!(s.doc().unwrap().contains(page)); +} + +#[test] +fn page_delete_keeps_a_frame_and_its_child() { + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f}))); + assert_eq!(s.doc().unwrap().get(c).unwrap().parent, Some(f)); + assert!(s.execute("page.delete", &json!({"id": f})).is_err()); + assert!(s.doc().unwrap().contains(f) && s.doc().unwrap().contains(c), "the frame and its child remain"); + assert!(s.execute("page.rename", &json!({"id": f, "name": "Not a page"})).is_err()); + assert_ne!(name(&s, f), "Not a page"); +} + +#[test] +fn page_commands_still_work_on_pages() { + let mut s = Session::new(); + let first = s.page().unwrap(); + let second = id_of(&ex(&mut s, "page.add", json!({}))); + ex(&mut s, "page.rename", json!({"id": second, "name": "Flows"})); + assert_eq!(name(&s, second), "Flows"); + ex(&mut s, "page.setBackground", json!({"id": second, "color": "#123456"})); + match s.doc().unwrap().get(second).unwrap().kind { + NodeKind::Page { background, .. } => assert_eq!(background.to_css_hex().to_uppercase(), "#123456"), + _ => panic!("not a page"), + } + ex(&mut s, "page.delete", json!({"id": second})); + assert!(!s.doc().unwrap().contains(second)); + assert!(s.execute("page.delete", &json!({"id": first})).is_err(), "the last ordinary page stays"); +} diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index c1016c29..d299b0df 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -337,6 +337,17 @@ fn pages_by_the_documented_rules() { assert_ne!(s.page().unwrap(), div, "deleting the open page opens a page, never a divider"); } +#[test] +fn page_move_refuses_what_is_not_a_page() { + let mut s = Session::new(); + let r = rect(&mut s, "r", 30.0, 40.0, None); + let before = pages(&mut s); + assert!(s.execute("page.move", &json!({"id": r, "index": 0})).is_err(), "a layer id is not a page"); + assert!(s.execute("page.move", &json!({"id": 999999, "index": 0})).is_err(), "a page that isn't there is not a page"); + assert_eq!(pages(&mut s), before, "the page list is untouched"); + assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out"); +} + #[test] fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() { let mut s = Session::new(); @@ -414,3 +425,25 @@ fn duplicating_a_layer_named_past_the_counter_does_not_overflow() { assert_eq!(name(&s, dup), "Layer 18446744073709551615 2"); assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out"); } + +#[test] +fn page_navigation_skips_dividers() { + // A trailing divider: Next page stays on the ordinary page. + let mut s = Session::new(); + let first = s.page().unwrap(); + let div = id(&ex(&mut s, "page.add", json!({"name": "-"}))); + assert!(s.execute("page.select", &json!({"id": div})).is_err(), "a divider isn't a page to open"); + let r = ex(&mut s, "view.nextPage", json!({})); + assert_eq!(s.page().unwrap(), first, "Next page doesn't open a trailing divider"); + assert_eq!(id_of(&r["page"]), Some(first), "the result names the current page"); + // A middle divider: Previous page crosses it, Next page crosses it back. + let later = id(&ex(&mut s, "page.add", json!({"name": "Later"}))); + assert_eq!(s.page().unwrap(), later); + let r = ex(&mut s, "view.prevPage", json!({})); + assert_eq!(s.page().unwrap(), first, "Previous page crosses a divider"); + assert_eq!(id_of(&r["page"]), Some(first)); + ex(&mut s, "view.nextPage", json!({})); + assert_eq!(s.page().unwrap(), later, "Next page crosses a divider"); + ex(&mut s, "view.nextPage", json!({})); + assert_eq!(s.page().unwrap(), later, "Next page stops on the last page"); +} diff --git a/crates/engine/src/shapebuilder.rs b/crates/engine/src/shapebuilder.rs index 09a6758a..99d59b45 100644 --- a/crates/engine/src/shapebuilder.rs +++ b/crates/engine/src/shapebuilder.rs @@ -322,7 +322,7 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result { let whole_shape = touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x))); if merge && whole_shape { - return Ok(json!({"id": null, "changed": [], "removed": [], "regions": pick})); + return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick})); } let doc = s.doc_mut()?; let created = if merge { diff --git a/crates/engine/src/synthetic.rs b/crates/engine/src/synthetic.rs index 0cb3e148..c2a9f5d6 100644 --- a/crates/engine/src/synthetic.rs +++ b/crates/engine/src/synthetic.rs @@ -66,7 +66,8 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { made += 4; } // A card group (5 layers). - if budget(made) >= 5 { + // The card and the instances leave one layer for the grid, which starts with a rectangle. + if budget(made) > 5 { let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?; // Children are in the group's space; normalising fits the group around them. let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?; @@ -78,7 +79,7 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { } // Three button instances (3 layers each). for k in 0..3 { - if budget(made) < 3 { + if budget(made) < 3 + 1 { break; } crate::components::create_instance(&mut doc, button, screen, 12.0 + k as f64 * 108.0, SCREEN_H - 44.0)?; @@ -89,12 +90,14 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { for k in 0..rest { let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0); let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0; - let id = match k % 5 { + // The grid starts with a rectangle so even the smallest workload has one. + let kind = (k + 2) % 5; + let id = match kind { 0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?, 1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?, _ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?, }; - if k % 5 != 0 { + if kind != 0 { fill(&mut doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0)); if let Some(n) = doc.node_mut(id) { n.corners.radii = [3.0; 4]; @@ -124,4 +127,16 @@ mod tests { assert!(doc.page_content_bounds(page).is_some()); } } + + #[test] + fn small_documents_hold_the_benchmark_targets() { + for n in [10, 11, 12, 13, 14, 15] { + let (doc, page) = super::large_document(n).unwrap(); + let nodes: Vec<_> = doc.subtree(page).into_iter().filter_map(|id| doc.node(id)).collect(); + let has = |f: &dyn Fn(&figcraft_doc::Node) -> bool| nodes.iter().any(|node| f(node)); + assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Rectangle) && x.name == "Rectangle"), "{n}: no rectangle"); + assert!(has(&|x| x.name == "Title"), "{n}: no title"); + assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Component { .. })), "{n}: no component"); + } + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index be5b4216..d8ebfc9e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -600,3 +600,27 @@ fn guides_add_move_remove() { s.execute("guide.clear", &json!({})).unwrap(); assert!(guides(&s).is_empty()); } + +#[test] +fn find_replace_matches_and_replaces_case_insensitively() { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap()); + let u = id(&s.execute("text.create", &json!({"text": "İ HELLO hello", "x": 0, "y": 40})).unwrap()); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "HELLO box"})).unwrap()); + let chars = |s: &Session, i| s.doc().unwrap().node(i).unwrap().kind.text().unwrap().characters.clone(); + let r = s.execute("edit.findReplace", &json!({"find": "hello", "replace": "bye", "layerNames": true})).unwrap(); + assert_eq!(r["matches"], json!([t, u, f])); + assert_eq!(chars(&s, t), "bye"); + assert_eq!(chars(&s, u), "İ bye bye"); + assert_eq!(s.doc().unwrap().node(f).unwrap().name, "bye box"); +} + +#[test] +fn find_replace_changing_only_the_case_replaces_every_occurrence() { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": "figcraft and FIGCRAFT, figcraft", "x": 0, "y": 0})).unwrap()); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "figcraft / figcraft"})).unwrap()); + s.execute("edit.findReplace", &json!({"find": "figcraft", "replace": "FigCraft", "layerNames": true})).unwrap(); + assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); + assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); +} diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 51550f7e..84315f1f 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -192,3 +192,40 @@ fn paragraph_indent_only_moves_left_aligned_text() { let (x, w2) = first_x(&s); assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it"); } + +/// A new text made with the Text tool by a press at `from`, a drag to `to` and a release, holding +/// `chars`: its box and resize mode. +fn text_tool(from: (f64, f64), to: (f64, f64), chars: &str) -> (figcraft_geom::Rect, figcraft_doc::TextAutoResize) { + use crate::tools::{PointerEvent, PointerKind, ToolId}; + let mut s = Session::new(); + s.set_tool(ToolId::Text); + s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap(); + crate::textedit::insert(&mut s, chars).unwrap(); + let t = s.ds().unwrap().text_edit.as_ref().unwrap().id; + let doc = s.doc().unwrap(); + let NodeKind::Text(td) = &doc.node(t).unwrap().kind else { panic!("not a text") }; + (doc.world_bounds(t).unwrap(), td.auto_resize) +} + +#[test] +fn a_drag_with_the_text_tool_makes_a_fixed_size_box() { + // #360: the drag's size, its top-left where the drag started, and its text wraps in it; a + // click, or a drag within a few pixels, still makes auto-width text. + use figcraft_doc::TextAutoResize; + figcraft_text::scan_system_fonts(); + let long = "A sentence much longer than eighty pixels"; + let (line, auto) = text_tool((100.0, 100.0), (100.0, 100.0), long); + assert_eq!(auto, TextAutoResize::WidthAndHeight); + assert_eq!(text_tool((100.0, 100.0), (101.0, 101.0), long).1, TextAutoResize::WidthAndHeight, "a slip of the hand is a click"); + let (b, auto) = text_tool((100.0, 100.0), (180.0, 400.0), long); + assert_eq!(auto, TextAutoResize::None); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (100.0, 100.0, 80.0, 300.0), "the text wraps in a box that keeps its size"); + // Dragged up and to the left, the box is the same. + let (b2, _) = text_tool((180.0, 400.0), (100.0, 100.0), long); + assert_eq!((b2.x0, b2.y0, b2.width(), b2.height()), (100.0, 100.0, 80.0, 300.0)); + // A flat drag still makes a box one line tall. + let (b3, _) = text_tool((100.0, 100.0), (180.0, 101.0), "A"); + assert_eq!((b3.width(), b3.height()), (80.0, line.height())); +} diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 6ba94217..5e5762b5 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -38,16 +38,17 @@ pub fn end(s: &mut Session) -> Result<()> { let _ = doc.remove(te.id); s.ds_mut()?.selection.clear(); } - s.commit_interaction()?; if empty && te.created { - // Nothing to undo for a text box that was never filled. + // Nothing to undo for a text box that was never filled. The gesture snapshot was taken + // after the layer was added, so it still holds it: re-base it on the document without the + // layer, and committing then records no step (and leaves the redo stack alone). let ds = s.ds_mut()?; - if let Some(last) = ds.history.undo.last() - && *last.doc == *ds.doc - { - ds.history.undo.pop(); + let doc = ds.doc.clone(); + if let Some(it) = ds.interaction.as_mut() { + it.doc = doc; } } + s.commit_interaction()?; let ds = s.ds_mut()?; ds.revision += 1; Ok(()) @@ -146,9 +147,15 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result { } /// Cmd/Ctrl+] and Cmd/Ctrl+[ while editing: the selected list items a level deeper or out, like -/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list stays as it is). +/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list and items already +/// at the deepest level stay as they are). pub fn list_indent(s: &mut Session, out: bool) -> Result { - list_key(s, "Tab", out) + let Some(id) = s.ds()?.text_edit.as_ref().map(|te| te.id) else { return Ok(false) }; + let before = text_of(s, id)?; + if !list_key(s, "Tab", out)? { + return Ok(false); + } + Ok(text_of(s, id)? != before) } /// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word diff --git a/crates/engine/src/theme_pages_tests.rs b/crates/engine/src/theme_pages_tests.rs index feaf4c6d..5172bb4b 100644 --- a/crates/engine/src/theme_pages_tests.rs +++ b/crates/engine/src/theme_pages_tests.rs @@ -182,3 +182,49 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() { let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap(); assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]); } + +#[test] +fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() { + let mut s = Session::new(); + ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})); + let start = ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100}))["id"].clone(); + ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Checkout"})); + ex(&mut s, "proto.settings", json!({"device": "iPhone 16", "responsive": true})); + ex(&mut s, "guide.add", json!({"vertical": true, "offset": 25})); + ex(&mut s, "select.none", json!({})); + let source = s.page().unwrap(); + let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap(); + let frames = s.doc().unwrap().node(copy).unwrap().children.clone(); + let state = |s: &Session, id: NodeId| match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Page { flows, prototype, guides, .. } => (flows.clone(), prototype.clone(), guides.clone()), + _ => panic!("expected a page"), + }; + let (flows, prototype, guides) = state(&s, copy); + let (src_flows, src_prototype, src_guides) = state(&s, source); + assert_eq!(flows.len(), 1); + assert_eq!(flows[0].name, "Checkout"); + assert_eq!(flows[0].start, frames[1], "the flow starts at the copied frame"); + assert_eq!(src_flows[0].start, id_of(&start).unwrap(), "the source keeps its own"); + assert_eq!(prototype, src_prototype); + assert_eq!(prototype.device, "iPhone 16"); + assert!(prototype.responsive); + assert_eq!(guides, src_guides); + assert_eq!(guides.len(), 1); + assert_eq!(ex(&mut s, "proto.present", json!({}))["start"].as_u64(), Some(frames[1].0)); +} + +#[test] +fn a_copy_of_a_page_keeps_a_flow_that_starts_in_a_section() { + let mut s = Session::new(); + let start = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap(); + ex(&mut s, "object.wrapInSection", json!({"ids": [start]})); + ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Onboarding"})); + let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap(); + let doc = s.doc().unwrap(); + let section = doc.node(copy).unwrap().children[0]; + let frame = doc.node(section).unwrap().children[0]; + match &doc.node(copy).unwrap().kind { + NodeKind::Page { flows, .. } => assert_eq!(flows.iter().map(|f| (f.name.as_str(), f.start)).collect::>(), vec![("Onboarding", frame)]), + _ => panic!("expected a page"), + } +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index ddd276f9..3d3b9acb 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -376,6 +376,13 @@ pub(crate) enum Gesture { TextSelect { id: NodeId, }, + /// A new text from the Text tool, still pressed: a drag makes it a box the size of the drag + /// (at least `line`, the height of its first line, tall). + TextBox { + id: NodeId, + start: Point, + line: f64, + }, PenDrag { anchor: Point, }, @@ -1400,6 +1407,11 @@ impl Session { self.tool.gesture = g; return self.draw_drag(ev, start, id, parent, &targets); } + Gesture::TextBox { id, start, line } => { + let (id, start, line) = (*id, *start, *line); + self.tool.gesture = g; + return self.text_box_drag(ev, id, start, line); + } Gesture::Arc { id, handle, arc } => { let (id, handle, arc) = (*id, *handle, *arc); self.tool.gesture = g; @@ -1444,7 +1456,8 @@ impl Session { } Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()), Gesture::PenDrag { anchor } => { - let out = ev.p() - *anchor; + // A click's wobble pulls out no handle. + let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor }; if let Some(pen) = self.tool.pen.as_mut() && let Some(last) = pen.points.last_mut() { @@ -1818,7 +1831,7 @@ impl Session { self.tool.overlay.pen.clear(); self.pencil_finish(&points, &pressure)?; } - Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {} + Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} Gesture::Vector(_) => self.commit_interaction()?, } @@ -1844,9 +1857,10 @@ impl Session { self.widget_click(ev.p(), ev.tol() * 2.0)?; return Ok(()); } - // The second press began a move or a resize: a double-click isn't one. - if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. }) { + // The second press began a move, a resize, a shape or a marquee: a double-click isn't one. + if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) { self.tool.gesture = Gesture::None; + self.tool.overlay.marquee = None; self.cancel_interaction()?; } let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; @@ -1943,15 +1957,39 @@ impl Session { n.width = w.max(1.0); n.height = h; } + let line = n.height; d.insert(n, parent, None)?; ds.selection = vec![id]; textedit::begin(self, id, Some(0), true)?; + self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line }; if !self.prefs.keep_tool { self.tool.tool = ToolId::Move; } Ok(()) } + /// Drag out a new text box: past the drag slop it has the drag's size (at least one line + /// tall) and its top-left, and that size is fixed. + fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { + if within_slop(ev, start) { + return Ok(()); + } + let r = Rect::from_points(start, ev.p()); + let d = self.ds_mut()?.doc_mut(); + let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default(); + let n = d.get_mut(id)?; + n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round())); + n.width = r.width().round().max(1.0); + n.height = r.height().round().max(line); + n.child_layout.sizing_h = Sizing::Fixed; + n.child_layout.sizing_v = Sizing::Fixed; + if let NodeKind::Text(t) = &mut n.kind { + t.auto_resize = TextAutoResize::None; + } + self.ds_mut()?.revision += 1; + Ok(()) + } + fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let doc = ds.doc.clone(); diff --git a/crates/engine/src/variables_editor_tests.rs b/crates/engine/src/variables_editor_tests.rs index 815fcb2b..9518911e 100644 --- a/crates/engine/src/variables_editor_tests.rs +++ b/crates/engine/src/variables_editor_tests.rs @@ -114,3 +114,14 @@ fn field_scopes_by_property_and_layer() { assert_eq!(scope(f, "paddingHorizontal"), Some("GAP")); assert_eq!(scope(f, "visible"), None); } + +#[test] +fn create_collection_refuses_a_taken_name() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Theme"})); + let before = s.doc().unwrap().variables.collections.len(); + assert!(s.execute("variable.createCollection", &json!({"name": "Theme"})).is_err()); + assert_eq!(s.doc().unwrap().variables.collections.len(), before); + ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"})); + assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1); +} diff --git a/crates/engine/src/vectoredit/ops.rs b/crates/engine/src/vectoredit/ops.rs index 093d0854..935f033a 100644 --- a/crates/engine/src/vectoredit/ops.rs +++ b/crates/engine/src/vectoredit/ops.rs @@ -3,7 +3,7 @@ //! `vectoredit.rs` re-fit the node afterwards. use figcraft_doc::*; -use figcraft_geom::kurbo::{CubicBez, ParamCurve}; +use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest}; use figcraft_geom::{Affine, Point, Vec2}; fn tvec(t: (f64, f64)) -> Vec2 { @@ -236,6 +236,12 @@ pub fn bend(net: &mut VectorNetwork, seg: usize, t: f64, p: Point) { pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option { let s = net.segments.get(seg).copied()?; let c: CubicBez = net.cubic(seg)?; + // Which way each region loop walks `seg`, read before the split changes its endpoints. + let reversed: Vec> = net + .regions + .iter() + .map(|reg| reg.loops.iter().map(|lp| net.loop_walk(lp).iter().find(|(i, _)| *i == seg).is_some_and(|(_, rev)| *rev)).collect()) + .collect(); let t = if t.is_finite() { t.clamp(0.01, 0.99) } else { 0.5 }; let l = c.subsegment(0.0..t); let r = c.subsegment(t..1.0); @@ -263,10 +269,12 @@ pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option Vec<(f64, if ia { b } else { a } }; let n = 64; + // Upper bound on the curve's speed: no point of a sample interval is farther than the whole + // interval length from either end, so only intervals that come that close need a closer look. + let speed = 3.0 * [(cb.p1 - cb.p0).hypot(), (cb.p2 - cb.p1).hypot(), (cb.p3 - cb.p2).hypot()].into_iter().fold(0.0, f64::max); + let reach = speed / f64::from(n); + // A crossing narrower than an interval whose ends are both outside: the closest point. + let dip = |a: f64, b: f64| -> Option { + if (cb.eval(a) - c).hypot().min((cb.eval(b) - c).hypot()) > r + reach { + return None; + } + let near = cb.subsegment(a..b).nearest(c, 1e-12); + let m = a + (b - a) * near.t; + (m > a && m < b && inside(m)).then_some(m) + }; let mut out = Vec::new(); let mut start: Option = if inside(0.0) { Some(0.0) } else { None }; let mut prev = 0.0; @@ -773,6 +794,11 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64, let t = f64::from(i) / f64::from(n); match (start, inside(t)) { (None, true) => start = Some(edge(prev, t)), + (None, false) => { + if let Some(m) = dip(prev, t) { + out.push((edge(prev, m), edge(m, t))); + } + } (Some(s0), false) => { out.push((s0, edge(prev, t))); start = None; @@ -856,6 +882,38 @@ mod tests { assert!((a + b).hypot() < 1e-9, "made symmetric: {a:?} {b:?}"); } + fn loop_is_continuous(n: &VectorNetwork, lp: &[usize]) -> bool { + let ends: Vec<(usize, usize)> = n + .loop_walk(lp) + .iter() + .map(|&(i, rev)| { + let s = n.segments[i]; + if rev { (s.b, s.a) } else { (s.a, s.b) } + }) + .collect(); + (0..ends.len()).all(|k| ends[k].1 == ends[(k + 1) % ends.len()].0) + } + + #[test] + fn split_keeps_region_loop_in_ring_order() { + // Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards. + let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false); + n.segments.push(Segment::line(0, 2)); + n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero }); + let p = Point::new(25.0, 10.0); + assert!(n.fill_contains(p)); + for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] { + let mut m = n.clone(); + m.regions[0].loops = vec![lp]; + let mid = split_segment(&mut m, seg, 0.5).unwrap(); + assert!(loop_is_continuous(&m, &m.regions[0].loops[0]), "loop {:?}", m.regions[0].loops); + assert!(m.fill_contains(p), "split of {seg} removed {p:?} from the fill"); + if seg == 1 { + assert_eq!(m.vertices[mid].point(), Point::new(100.0, 50.0)); + } + } + } + #[test] fn inferred_mirroring() { let mut n = open_line(); @@ -999,4 +1057,38 @@ mod tests { assert!(!erase(&mut m, Point::new(f64::NAN, 0.0), 5.0)); assert!(!erase(&mut m, Point::new(0.0, 0.0), -1.0)); } + + #[test] + fn eraser_catches_narrow_crossing_between_samples() { + // A radius-1 brush on a long line: the crossing fits between the sampled parameters. + let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false); + assert!(erase(&mut n, Point::new(5050.0, 0.0), 1.0)); + assert_eq!(n.segments.len(), 2); + let ends: Vec = n.vertices.iter().map(|v| v.x).collect(); + assert!(ends.iter().any(|x| (x - 5049.0).abs() < 0.01) && ends.iter().any(|x| (x - 5051.0).abs() < 0.01), "{ends:?}"); + assert!(ends.iter().any(|x| x.abs() < 1e-9) && ends.iter().any(|x| (x - 6400.0).abs() < 1e-9), "{ends:?}"); + // A curved segment that only grazes the brush between samples. + let mut c = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false); + c.segments[0].ta = (0.0, 3000.0); + c.segments[0].tb = (0.0, 3000.0); + let cb = c.cubic(0).unwrap(); + let t = 0.7039; + let p = cb.eval(t); + let d = figcraft_geom::kurbo::ParamCurveDeriv::deriv(&cb).eval(t).to_vec2().normalize(); + let normal = Vec2::new(-d.y, d.x); + assert!(erase(&mut c, p + normal * 0.5, 1.0)); + assert_eq!(c.segments.len(), 2); + } + + #[test] + fn eraser_catches_crossing_at_full_speed_between_samples() { + // The line moves fastest at t = 0.5; centre a tiny brush halfway between two samples there. + let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false); + let centre = n.cubic(0).unwrap().eval(32.5 / 64.0); + assert!(erase(&mut n, centre, 0.5)); + assert_eq!(n.segments.len(), 2); + let ends: Vec = n.vertices.iter().map(|v| v.x).collect(); + assert!(ends.iter().any(|x| (x - (centre.x - 0.5)).abs() < 0.01), "{ends:?}"); + assert!(ends.iter().any(|x| (x - (centre.x + 0.5)).abs() < 0.01), "{ends:?}"); + } } diff --git a/crates/engine/src/view_behaviour_tests.rs b/crates/engine/src/view_behaviour_tests.rs index c22cd1f5..27bc3771 100644 --- a/crates/engine/src/view_behaviour_tests.rs +++ b/crates/engine/src/view_behaviour_tests.rs @@ -456,6 +456,23 @@ fn a_selected_guide_is_deleted_and_nudged_with_the_keyboard() { assert!(s.execute("guide.select", &json!({"index": 7})).is_err(), "only an existing guide can be selected"); } +#[test] +fn removing_an_earlier_guide_keeps_the_selected_guide() { + let mut s = Session::new(); + for offset in [100, 200, 300] { + ex(&mut s, "guide.add", json!({"vertical": true, "offset": offset})); + } + let page = s.page().unwrap(); + ex(&mut s, "guide.select", json!({"index": 1})); + ex(&mut s, "guide.remove", json!({"index": 0})); + assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)), "the guide at 200 stays selected at its new index"); + assert!(s.tool_key("ArrowRight", false, false, false).unwrap()); + assert_eq!(guide_list(&s, page), vec![(true, 201.0), (true, 300.0)], "the arrow nudges the selected guide, not its neighbour"); + // A later guide leaves the selection alone. + ex(&mut s, "guide.remove", json!({"index": 1})); + assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0))); +} + #[test] fn guides_snap_and_measure() { let mut s = Session::new(); diff --git a/crates/engine/tests/empty_text_box_undo.rs b/crates/engine/tests/empty_text_box_undo.rs new file mode 100644 index 00000000..ec73b83a --- /dev/null +++ b/crates/engine/tests/empty_text_box_undo.rs @@ -0,0 +1,60 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_engine::{ + Session, + doc::NodeKind, + tools::{PointerEvent, PointerKind}, +}; +use serde_json::json; + +fn text_count(s: &Session) -> usize { + let doc = s.doc().unwrap(); + doc.node(s.page().unwrap()).unwrap().children.iter().filter(|id| matches!(doc.node(**id).unwrap().kind, NodeKind::Text(_))).count() +} + +fn rect_count(s: &Session) -> usize { + let doc = s.doc().unwrap(); + doc.node(s.page().unwrap()).unwrap().children.len() - text_count(s) +} + +fn click_and_abandon(s: &mut Session) { + s.execute("tool.text", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 300.0, 300.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 300.0, 300.0, 1.0)).unwrap(); + assert_eq!(text_count(s), 1); + s.execute("text.edit.end", &json!({})).unwrap(); + assert_eq!(text_count(s), 0); +} + +#[test] +fn abandoning_empty_text_does_not_create_an_undo_step() { + let mut s = Session::new(); + assert_eq!(s.undo_depth(), (0, 0)); + click_and_abandon(&mut s); + assert_eq!(s.undo_depth(), (0, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_count(&s), 0, "Undo restored the abandoned empty layer"); +} + +#[test] +fn abandoning_empty_text_keeps_the_previous_undo_step_first() { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + click_and_abandon(&mut s); + assert_eq!(s.undo_labels(2), vec!["Rectangle".to_string()]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_count(&s), 0); + assert_eq!(rect_count(&s), 0, "the first Undo must remove the rectangle"); +} + +#[test] +fn abandoning_empty_text_keeps_the_redo_stack() { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.undo_depth(), (0, 1)); + click_and_abandon(&mut s); + assert_eq!(s.undo_depth(), (0, 1), "the document is back where it was, so Redo still applies"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(rect_count(&s), 1); +} diff --git a/crates/engine/tests/library_style_acceptance.rs b/crates/engine/tests/library_style_acceptance.rs new file mode 100644 index 00000000..8398fc72 --- /dev/null +++ b/crates/engine/tests/library_style_acceptance.rs @@ -0,0 +1,72 @@ +//! Accepting a library update that changes only a paint style refreshes the layers linked to it. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_doc::{NodeId, StyleKind}; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap() +} + +fn layer_paint(s: &Session, id: NodeId) -> String { + s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex() +} + +fn style_paint(s: &Session, id: &str) -> String { + match &s.doc().unwrap().style(id).unwrap().kind { + StyleKind::Paint { paints } => paints[0].solid_color().unwrap().to_hex(), + _ => panic!("expected paint style"), + } +} + +struct Fixture(std::path::PathBuf); + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn accepted_library_paint_reaches_linked_layer() { + let fixture = Fixture(std::env::temp_dir().join(format!("figcraft-style-accept-{}", std::process::id()))); + let _ = std::fs::remove_dir_all(&fixture.0); + std::fs::create_dir(&fixture.0).unwrap(); + let path = fixture.0.join("library.figcraft"); + let mut publisher = Session::new(); + run(&mut publisher, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"})); + let publication = run(&mut publisher, "library.publish", json!({})); + assert_eq!(publication["components"], 0); + assert_eq!(publication["styles"], 1); + run(&mut publisher, "file.saveAs", json!({"path":path})); + let mut consumer = Session::new(); + let rectangle = run(&mut consumer, "shape.rectangle", json!({"width":40,"height":40})); + let layer = NodeId(rectangle["id"].as_u64().unwrap()); + run(&mut consumer, "library.add", json!({"path":path})); + let applied = run(&mut consumer, "library.applyStyle", json!({"library":publication["id"],"style":"Brand","id":layer})); + let style = applied["style"].as_str().unwrap(); + assert_eq!(layer_paint(&consumer, layer), "0000FF"); + run(&mut publisher, "style.update", json!({"style":"Brand","value":"#00ff00"})); + run(&mut publisher, "library.publish", json!({})); + run(&mut publisher, "file.save", json!({})); + assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 1); + let accepted = run(&mut consumer, "library.acceptUpdates", json!({})); + assert_eq!(accepted["styles"], 1); + assert_eq!(accepted["components"], 0); + assert_eq!(accepted["variables"], 0); + assert_eq!(style_paint(&consumer, style), "00FF00"); + assert_eq!(consumer.doc().unwrap().node(layer).unwrap().styles.fill.as_deref(), Some(style)); + // Captured before any reapply so a later action can't hide a missed refresh. + let after_accept = layer_paint(&consumer, layer); + assert_eq!(after_accept, "00FF00"); + assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 0); + consumer.undo().unwrap(); + assert_eq!(style_paint(&consumer, style), "0000FF"); + assert_eq!(layer_paint(&consumer, layer), "0000FF"); + consumer.redo().unwrap(); + assert_eq!(style_paint(&consumer, style), "00FF00"); + assert_eq!(layer_paint(&consumer, layer), "00FF00"); + run(&mut consumer, "style.apply", json!({"style":style,"id":layer})); + assert_eq!(layer_paint(&consumer, layer), "00FF00"); +} diff --git a/crates/engine/tests/list_indent_response.rs b/crates/engine/tests/list_indent_response.rs new file mode 100644 index 00000000..1dae8754 --- /dev/null +++ b/crates/engine/tests/list_indent_response.rs @@ -0,0 +1,71 @@ +//! `text.listIndent` / `text.listOutdent` report `changed` only when the document changed. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_engine::{ + Session, + doc::{ListStyle, NodeId, NodeKind}, +}; +use serde_json::json; + +fn editing_list(indent: u8) -> (Session, NodeId) { + let mut s = Session::new(); + let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap(); + let id = NodeId(value["id"].as_u64().unwrap()); + s.execute("text.listStyle", &json!({"ids":[id.0], "style":"bulleted"})).unwrap(); + s.execute("node.set", &json!({"id":id.0, "props":{"indentation":indent}})).unwrap(); + s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap(); + (s, id) +} + +fn list_state(s: &Session, id: NodeId) -> (ListStyle, u8) { + let NodeKind::Text(t) = &s.doc().unwrap().node(id).unwrap().kind else { panic!("not text") }; + let p = t.para_list(0); + (p.list, p.indent) +} + +#[test] +fn indent_at_cap_reports_false() { + let (mut s, id) = editing_list(8); + let before = s.doc().unwrap().clone(); + let result = s.execute("text.listIndent", &json!({})).unwrap(); + assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 8)); + assert_eq!(before, *s.doc().unwrap()); + assert_eq!(result["changed"], json!(false)); +} + +#[test] +fn indent_from_zero_reports_true() { + let (mut s, id) = editing_list(0); + let result = s.execute("text.listIndent", &json!({})).unwrap(); + assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1)); + assert_eq!(result["changed"], json!(true)); +} + +#[test] +fn outdent_from_zero_reports_true() { + let (mut s, id) = editing_list(0); + let result = s.execute("text.listOutdent", &json!({})).unwrap(); + assert_eq!(list_state(&s, id).0, ListStyle::None); + assert_eq!(result["changed"], json!(true)); +} + +#[test] +fn indent_plain_text_reports_false() { + let mut s = Session::new(); + let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap(); + let id = NodeId(value["id"].as_u64().unwrap()); + s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap(); + let before = s.doc().unwrap().clone(); + let result = s.execute("text.listIndent", &json!({})).unwrap(); + assert_eq!(before, *s.doc().unwrap()); + assert_eq!(result["changed"], json!(false)); +} + +#[test] +fn tab_keys_still_nest_and_unnest() { + let (mut s, id) = editing_list(0); + s.execute("text.edit.key", &json!({"key":"Tab"})).unwrap(); + assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1)); + s.execute("text.edit.key", &json!({"key":"Tab", "shift":true})).unwrap(); + assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 0)); +} diff --git a/crates/engine/tests/scope_reset.rs b/crates/engine/tests/scope_reset.rs new file mode 100644 index 00000000..6d875d12 --- /dev/null +++ b/crates/engine/tests/scope_reset.rs @@ -0,0 +1,26 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_engine::Session; +use serde_json::json; + +fn scopes(session: &Session, variable: &str) -> Option> { + session.doc().unwrap().variables.get(variable).unwrap().scopes.clone() +} + +#[test] +fn null_restores_all_supported_properties() { + let mut session = Session::new(); + session.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap(); + let result = session.execute("variable.create", &json!({"collection": "Theme", "name": "Accent", "type": "color"})).unwrap(); + let variable = result["id"].as_str().unwrap(); + assert_eq!(scopes(&session, variable), None); + session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap(); + assert_eq!(scopes(&session, variable), Some(vec![])); + session.execute("variable.update", &json!({"variable": variable, "name": "Accent2"})).unwrap(); + assert_eq!(scopes(&session, variable), Some(vec![])); + assert_eq!(session.execute("variable.update", &json!({"variable": variable, "scopes": null})).unwrap(), json!({"ok": true})); + assert_eq!(scopes(&session, variable), None); + session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap(); + session.execute("variable.update", &json!({"variable": variable, "scopes": ["ALL_SCOPES"]})).unwrap(); + assert_eq!(scopes(&session, variable), None); +} diff --git a/crates/engine/tests/shape_builder_response.rs b/crates/engine/tests/shape_builder_response.rs new file mode 100644 index 00000000..e1426823 --- /dev/null +++ b/crates/engine/tests/shape_builder_response.rs @@ -0,0 +1,36 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +use figcraft_engine::Session; +use serde_json::{Value, json}; + +fn rectangle(s: &mut Session, x: f64) -> Value { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].clone() +} + +#[test] +fn merge_response_includes_outlines_for_no_op() { + let mut s = Session::new(); + let id = rectangle(&mut s, 0.0); + let regions = s.execute("shapeBuilder.regions", &json!({"ids": [id]})).unwrap(); + assert_eq!(regions["regions"].as_array().unwrap().len(), 1); + let before = serde_json::to_value(s.doc().unwrap()).unwrap(); + let result = s.execute("shapeBuilder.merge", &json!({"ids": [id], "regions": [0]})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), before); + assert_eq!(result["id"], Value::Null); + assert_eq!(result["changed"], json!([])); + assert_eq!(result["removed"], json!([])); + assert_eq!(result["regions"], json!([0])); + assert_eq!(result.get("outlines"), Some(&json!([]))); +} + +#[test] +fn merge_response_control_includes_outlines() { + let mut s = Session::new(); + let a = rectangle(&mut s, 0.0); + let b = rectangle(&mut s, 50.0); + let regions = s.execute("shapeBuilder.regions", &json!({"ids": [a, b]})).unwrap(); + assert_eq!(regions["regions"].as_array().unwrap().len(), 3); + let result = s.execute("shapeBuilder.merge", &json!({"ids": [a, b], "regions": [1]})).unwrap(); + assert!(result["id"].is_u64()); + assert_eq!(result.get("outlines"), Some(&json!([]))); + assert_eq!(result.as_object().unwrap().len(), 5); +} diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index f3b869ff..4777be18 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -450,6 +450,38 @@ fn shared_styles_and_their_links_round_trip() { assert_eq!(named(&nodes, "Title")["styleIdForText"]["guid"], named(&nodes, "Heading")["guid"]); } +#[test] +fn a_styled_layers_fill_variable_survives_a_round_trip() { + let mut doc = Document::new("Overlap"); + let page = doc.first_page().unwrap(); + let mode = |id: &str, name: &str| Mode { id: id.into(), name: name.into() }; + doc.variables.collections = + vec![Collection { id: "c-theme".into(), name: "Theme".into(), modes: vec![mode("m-light", "Light"), mode("m-dark", "Dark")], extends: None }]; + let light = Color::new(0.1, 0.3, 0.7, 1.0); + let dark = VariableValue::Color(Color::new(0.8, 0.9, 1.0, 1.0)); + doc.variables.variables = + vec![var("v-accent", "Accent color", "c-theme", VariableType::Color, &[("m-light", VariableValue::Color(light)), ("m-dark", dark)])]; + doc.styles = vec![Style { + id: "s-accent".into(), + name: "Accent".into(), + description: String::new(), + kind: StyleKind::Paint { paints: vec![Paint::solid(light)] }, + bound: Default::default(), + }]; + add(&mut doc, page, NodeKind::Rectangle, "Swatch", (0.0, 0.0), (120.0, 80.0), &|n| { + n.fills = vec![Paint::solid(light)]; + n.styles.fill = Some("s-accent".into()); + n.bound.insert("fill".into(), "v-accent".into()); + }); + let (back, _) = import(&export(&doc, None).unwrap().0, "x").unwrap(); + let var = back.variables.variables.iter().find(|v| v.name == "Accent color").unwrap().id.clone(); + let swatch = back.nodes().find(|n| n.name == "Swatch").unwrap(); + let on_layer = swatch.bound.get("fill") == Some(&var) || swatch.bound.get("fills/0/color") == Some(&var); + let style = back.styles.iter().find(|s| Some(&s.id) == swatch.styles.fill.as_ref()).unwrap(); + let on_style = style.bound.get("fills/0/color") == Some(&var); + assert!(on_layer || on_style, "the fill binding was lost: layer {:?}, style {:?}", swatch.bound, style.bound); +} + #[test] fn text_styles_on_part_of_a_text_are_written_as_values() { let mut doc = styled(); diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 22243355..526ab3d9 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1429,9 +1429,15 @@ impl<'d> Exporter<'d> { } } - /// How a layer sizes and sits in its auto layout parent. + /// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own + /// and are written whatever its parent is. fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { let cl = &n.child_layout; + for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] { + if w.is_some() || h.is_some() { + f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))]))); + } + } let Some(pl) = parent.and_then(|p| p.layout.as_ref()) else { return }; let (fill_h, fill_v) = (cl.sizing_h == Sizing::Fill, cl.sizing_v == Sizing::Fill); let (grow, stretch) = if pl.mode == LayoutMode::Vertical { (fill_v, fill_h) } else { (fill_h, fill_v) }; @@ -1444,11 +1450,6 @@ impl<'d> Exporter<'d> { if cl.absolute { f.push(("stackPositioning", e("ABSOLUTE"))); } - for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] { - if w.is_some() || h.is_some() { - f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))]))); - } - } if pl.mode == LayoutMode::Grid && let Some((cols, rows)) = parent.and_then(|p| self.tracks.get(&self.main_layer(p.id))) { diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 8d66b72f..67215b0c 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -1014,6 +1014,37 @@ fn junk_documents_never_panic() { } } +/// A frame's own min/max size survives whatever its parent is, not only under auto layout. +#[test] +fn frame_size_limits_survive_without_auto_layout_parent() { + fn limits(doc: &Document) -> [Option; 4] { + let (bytes, ew) = export(doc, None).unwrap(); + let (back, iw) = import(&bytes, "File").unwrap(); + assert!(ew.is_empty() && iw.is_empty(), "export {ew:?}, import {iw:?}"); + let cl = &back.nodes().find(|n| n.name == "Subject").unwrap().child_layout; + [cl.min_w, cl.max_w, cl.min_h, cl.max_h] + } + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let id = doc.create(page, NodeKind::Frame, "Subject", 0.0, 0.0, 100.0, 100.0).unwrap(); + let node = doc.node_mut(id).unwrap(); + node.layout = Some(AutoLayout::default()); + node.child_layout.min_w = Some(80.0); + node.child_layout.max_w = Some(150.0); + node.child_layout.min_h = Some(60.0); + node.child_layout.max_h = Some(170.0); + let expected = [Some(80.0), Some(150.0), Some(60.0), Some(170.0)]; + + let mut nested = doc.clone(); + let wrapper = nested.create(page, NodeKind::Frame, "Wrapper", 0.0, 0.0, 300.0, 300.0).unwrap(); + nested.node_mut(wrapper).unwrap().layout = Some(AutoLayout::default()); + nested.reparent(id, wrapper, None).unwrap(); + assert_eq!(limits(&nested), expected, "auto layout parent"); + nested.node_mut(wrapper).unwrap().layout = None; + assert_eq!(limits(&nested), expected, "plain frame parent"); + assert_eq!(limits(&doc), expected, "page parent"); +} + /// Link each instance's layers to its main's by position, as they are imported (the engine /// links them by path after loading). fn link(doc: &mut Document) { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 787ca6d4..1e365567 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -25,6 +25,8 @@ const MAX_DEPTH: usize = 256; const MAX_COMMANDS: usize = 200_000; /// Id of "no style". const NO_STYLE: &str = "4294967295:4294967295"; +/// Longest asset `key` or `version` kept for resolving references given as assets. +const MAX_ASSET_PART: usize = 256; /// The REST API file JSON for message `msg`, with the warnings about what was skipped. pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { @@ -86,6 +88,10 @@ fn vec2(v: &Kv) -> Option { Some(json!({"x": num(v, "x")?, "y": num(v, "y")?})) } +fn size(v: &Kv) -> Option<(f64, f64)> { + Some((num(v, "x")?, num(v, "y")?)) +} + fn matrix(m: &Kv) -> Option { let c = |k: &str| num(m, k); let a = Affine::new([c("m00")?, c("m10")?, c("m01")?, c("m11")?, c("m02")?, c("m12")?]); @@ -113,11 +119,21 @@ fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) { #[derive(Clone)] struct View<'a> { layers: Vec<&'a Kv<'a>>, + /// Size and transform worked out from the constraints, over the layers' own. + fitted: Option<((f64, f64), Affine)>, } impl<'a> View<'a> { fn of(n: &'a Kv<'a>) -> Self { - Self { layers: vec![n] } + Self { layers: vec![n], fitted: None } + } + + fn size(&self) -> Option<(f64, f64)> { + self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(size)) + } + + fn transform(&self) -> Option { + self.fitted.map(|f| f.1).or_else(|| self.get("transform").and_then(matrix)) } fn with(&self, top: impl IntoIterator>) -> Self { @@ -147,13 +163,83 @@ impl<'a> View<'a> { } } +/// How the layers of a resized container follow it when the file gives no derived layout for +/// them: a frame's by their constraints, from its main's size to its own; a group's scale with it. +#[derive(Clone, Copy)] +enum Fit { + Frame { from: (f64, f64), to: (f64, f64) }, + Scale(f64, f64), +} + +impl Fit { + fn frame(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option { + let (from, to) = (from?, to?); + ((from.0 - to.0).abs() > 1e-9 || (from.1 - to.1).abs() > 1e-9).then_some(Fit::Frame { from, to }) + } + + fn scale(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option { + let (from, to) = (from?, to?); + let k = |a: f64, b: f64| if a > 1e-9 { b / a } else { 1.0 }; + let (kx, ky) = (k(from.0, to.0), k(from.1, to.1)); + ((kx - 1.0).abs() > 1e-9 || (ky - 1.0).abs() > 1e-9).then_some(Fit::Scale(kx, ky)) + } + + /// The size and transform (in the parent's coordinates) of a `w` × `h` layer at `t` with + /// constraints `h_con`, `v_con`. Like the editor's constraint solver, a layer keeps its + /// rotation and flip and stretches along its own axes. + fn apply(self, w: f64, h: f64, t: Affine, h_con: Option<&str>, v_con: Option<&str>) -> ((f64, f64), Affine) { + let [a, b, c, d, _, _] = t.as_coeffs(); + let lin = Affine::new([a, b, c, d, 0.0, 0.0]); + let bb = figcraft_doc::geom::transform_rect_bbox(t, figcraft_doc::geom::Rect::new(0.0, 0.0, w, h)); + // Where the new box goes: its top-left (constraints) or its centre (a group's scale). + let (kx, ky, x0, y0, centred) = match self { + Fit::Frame { from, to } => { + let axis = |con: Option<&str>, lo: f64, hi: f64, old: f64, new: f64| match con { + Some("MAX" | "FIXED_MAX") => (lo + new - old, hi + new - old), + Some("STRETCH") => (lo, hi + new - old), + Some("CENTER") => (lo + (new - old) / 2.0, hi + (new - old) / 2.0), + Some("SCALE") if old > 1e-9 => (lo * new / old, hi * new / old), + _ => (lo, hi), + }; + let (x0, x1) = axis(h_con, bb.x0, bb.x1, from.0, to.0); + let (y0, y1) = axis(v_con, bb.y0, bb.y1, from.1, to.1); + let k = |new: f64, old: f64| if old > 1e-9 { (new / old).max(0.0) } else { 1.0 }; + (k(x1 - x0, bb.width()), k(y1 - y0, bb.height()), x0, y0, false) + } + Fit::Scale(kx, ky) => (kx, ky, bb.center().x * kx, bb.center().y * ky, true), + }; + let along = |x: f64, y: f64, fallback: f64| { + let len = x.hypot(y); + let k = (kx * x).hypot(ky * y) / len; + if len > 1e-12 && k.is_finite() { k } else { fallback } + }; + let size = (w * along(a, b, kx), h * along(c, d, ky)); + let nb = figcraft_doc::geom::transform_rect_bbox(lin, figcraft_doc::geom::Rect::new(0.0, 0.0, size.0, size.1)); + let at = if centred { nb.center() } else { figcraft_doc::geom::Point::new(nb.x0, nb.y0) }; + (size, Affine::translate((x0 - at.x, y0 - at.y)) * lin) + } +} + +/// Overrides and derived layout by id path. +type Overrides<'a> = BTreeMap, Vec<(Entry, &'a Kv<'a>)>>; + +/// Where an override entry comes from. +#[derive(Clone, Copy, PartialEq)] +enum Entry { + Override, + Derived, + /// Derived layout of a nested instance for a size it no longer has: its size and place no + /// longer hold, the rest (resolved colours, text) still does. + Stale, +} + /// Where an instance's expanded layers are: the overrides and derived layout of the outermost /// instance (by id path), the path of the instance being expanded, and the property values. #[derive(Clone)] struct Inst<'a> { root: String, path: Vec, - overrides: Rc, Vec<&'a Kv<'a>>>>, + overrides: Rc>, props: Rc>>, /// Mains being expanded (an instance inside its own main stops there). symbols: Vec, @@ -165,6 +251,10 @@ struct Mapper<'a> { blobs: Vec<&'a [u8]>, /// Component property definitions: id → (REST name "Name#id", type). prop_defs: BTreeMap, + /// File nodes by asset `key` and `version`: references can name a collection, variable or + /// style as the asset instead of the guid, and the file carries the resources to resolve + /// them (the collection node itself says the same `key`). + assets: BTreeMap>, styles: Obj, /// Descriptions of components and component sets (REST `components` / `componentSets`). components: Obj, @@ -203,7 +293,37 @@ impl<'a> Mapper<'a> { } let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default().iter().map(|b| b.get("bytes").and_then(Kv::bytes).unwrap_or_default()).collect(); - Self { nodes, kids, blobs, prop_defs, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 } + let mut assets: BTreeMap> = BTreeMap::new(); + for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() { + let (Some(key), Some(id)) = (n.get("key").and_then(Kv::str), n.get("guid").and_then(guid)) else { continue }; + if key.is_empty() || key.len() > MAX_ASSET_PART { + continue; + } + let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or(""); + assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id); + } + Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 } + } + + /// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`, + /// else the file node its asset `key` and `version` names (a collection from a library is + /// referenced as the asset, and the file's own node for it says the same `key`). `None` when + /// the reference has neither or the asset names nothing (or ambiguously) in the file. + /// Original code for this importer: the field names are those of the schema each file embeds. + fn resource_id(&self, v: &Kv) -> Option { + if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE) { + return Some(g); + } + let asset = v.get("assetRef")?; + let key = asset.get("key").and_then(Kv::str)?; + let versions = self.assets.get(key)?; + let version = asset.get("version").and_then(Kv::str); + match version { + Some(v) => versions.get(v), + // Without a version: an unversioned record, else the file's only one. + None => versions.get("").or_else(|| (versions.len() == 1).then(|| versions.values().next()).flatten()), + } + .cloned() } fn warn(&mut self, w: impl Into) { @@ -263,7 +383,7 @@ impl<'a> Mapper<'a> { let name = sp.get("name").and_then(Kv::str).filter(|s| !s.is_empty()).unwrap_or("Flow"); flows.push((sp.get("position").and_then(Kv::str).unwrap_or(""), json!({"nodeId": c, "name": name}))); } - kids.extend(self.layer(&c, None, Affine::IDENTITY, None, 0)); + kids.extend(self.layer(&c, None, Affine::IDENTITY, None, None, 0)); } flows.sort_by(|a, b| a.0.cmp(b.0)); if !flows.is_empty() { @@ -276,7 +396,15 @@ impl<'a> Mapper<'a> { /// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the /// parent's coordinates to the enclosing frame's (groups and booleans place their children in /// the frame's space, like the REST JSON). `parent`: the parent's view (grid tracks). - fn layer(&mut self, id: &str, inst: Option<&Inst<'a>>, space: Affine, parent: Option<&View<'a>>, depth: usize) -> Option { + fn layer( + &mut self, + id: &str, + inst: Option<&Inst<'a>>, + space: Affine, + parent: Option<&View<'a>>, + fit: Option, + depth: usize, + ) -> Option { if depth > MAX_DEPTH { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; @@ -287,10 +415,28 @@ impl<'a> Mapper<'a> { } let base = *self.nodes.get(id)?; let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()); - let view = match (inst, &path) { - (Some(i), Some(p)) => View::of(base).with(i.overrides.get(p).into_iter().flatten().copied()), - _ => View::of(base), + let entries = match (inst, &path) { + (Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(), + _ => &[], }; + let mut view = View::of(base).with(entries.iter().map(|e| e.1)); + // A layer of a resized instance the file gives no derived layout for follows its + // constraints from the main's geometry, then takes the size and place overridden on it + // (auto layout places the others, as in the editor). + let derived = entries.iter().any(|(d, l)| *d == Entry::Derived && (l.get("size").is_some() || l.get("transform").is_some())); + let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID")) + && view.str("stackPositioning") != Some("ABSOLUTE"); + let main = View::of(base); + let set = |k: &str| entries.iter().rev().filter(|e| e.0 != Entry::Stale).find_map(|(_, l)| l.get(k)); + if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) { + let t = main.transform().unwrap_or_default(); + let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint")); + view.fitted = Some((set("size").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).unwrap_or(ft))); + } else if entries.iter().any(|(d, l)| *d == Entry::Stale && (l.get("size").is_some() || l.get("transform").is_some())) + && let Some(s) = set("size").and_then(size).or_else(|| main.size()) + { + view.fitted = Some((s, set("transform").and_then(matrix).or_else(|| main.transform()).unwrap_or_default())); + } let rest_id = match (inst, &path) { (Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")), _ => id.to_string(), @@ -338,21 +484,26 @@ impl<'a> Mapper<'a> { self.component(&mut o, &view, &rest_id); } self.prop_refs(&mut o, &view, inst); - let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { - space * view.get("transform").and_then(matrix).unwrap_or_default() - } else { - Affine::IDENTITY - }; + let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; let mut kids = Vec::new(); if ty == "INSTANCE" { if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) { + let main = self.nodes.get(&sym).map(|m| View::of(m)); + let fit = Fit::frame(main.and_then(|m| m.size()), view.size()); for c in self.children(&sym) { - kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), depth + 1)); + kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), fit, depth + 1)); } } } else { + // Inside an instance a layer's children are its main's: they follow it from the main's + // size to its own. + let fit = match (inst, ty) { + (None, _) => None, + (Some(_), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of(base).size(), view.size()), + (Some(_), _) => Fit::frame(View::of(base).size(), view.size()), + }; for c in self.children(id) { - kids.extend(self.layer(&c, inst, child_space, Some(&view), depth + 1)); + kids.extend(self.layer(&c, inst, child_space, Some(&view), fit, depth + 1)); } } if stand.is_some_and(|s| s.0 == "FRAME") { @@ -487,18 +638,22 @@ impl<'a> Mapper<'a> { if let Some(b) = v.str("blendMode").filter(|b| *b != "UNSET") { o.insert("blendMode".into(), json!(b)); } - if let Some(s) = v.get("size").and_then(vec2) { - o.insert("size".into(), s); + if let Some((x, y)) = v.size() { + o.insert("size".into(), json!({"x": x, "y": y})); } - let t = v.get("transform").and_then(matrix).unwrap_or_default(); + let t = v.transform().unwrap_or_default(); o.insert("relativeTransform".into(), rows(space * t)); - // Paints, from the shared style when one is linked (the layer's own copy can be stale). + // Paints, from the shared style when one is linked (the layer's own copy can be stale), + // unless the layer's own paints are bound to variables: those bindings must not be lost. let mut styles = Obj::new(); + let own = |k: &str| Some(v.list(k)).filter(|l| l.iter().any(paint_bound)); let fill_style = self.style(v, "styleIdForFill", "inheritFillStyleID", "fill", &mut styles); - let fills = fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()); + let fills = own("fillPaints") + .unwrap_or_else(|| fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); o.insert("fills".into(), Value::Array(fills.iter().filter_map(paint).collect())); let stroke_style = self.style(v, "styleIdForStrokeFill", "inheritFillStyleIDForStroke", "stroke", &mut styles); - let strokes = stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()); + let strokes = own("strokePaints") + .unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(paint).collect())); let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles); let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default()); @@ -739,7 +894,8 @@ impl<'a> Mapper<'a> { vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0); let scale = |k: &str| { - let (size, norm) = (v.get("size").and_then(|s| num(s, k)), vd.get("normalizedSize").and_then(|s| num(s, k))); + let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 }); + let norm = vd.get("normalizedSize").and_then(|s| num(s, k)); size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n) }; let (sx, sy) = (scale("x"), scale("y")); @@ -908,7 +1064,15 @@ impl<'a> Mapper<'a> { } // Overrides by id path below this instance (the instance itself is addressed by its main's id). let own_path: Vec = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default(); - let mut overrides: BTreeMap, Vec<&'a Kv<'a>>> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default(); + let mut overrides: Overrides<'a> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default(); + // A nested instance's derived layout is for the size it has in its main: resized here, the + // sizes and places in it no longer hold and its layers follow their constraints instead. + let differs = |a: f64, b: f64| (a - b).abs() > 1e-6 * a.abs().max(b.abs()).max(1.0); + let resized = outer.is_some() + && match (self.nodes.get(id).and_then(|b| View::of(b).size()), v.size()) { + (Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1), + (a, b) => a.is_some() != b.is_some(), + }; let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")]; for (layer, list) in lists.into_iter().enumerate() { for ov in list { @@ -920,9 +1084,16 @@ impl<'a> Mapper<'a> { let e = overrides.entry([own_path.as_slice(), &rel].concat()).or_default(); // The outer instance's overrides win over this one's own; derived layout comes last. if layer == 0 && outer.is_some() { - e.insert(0, ov); + e.insert(0, (Entry::Override, ov)); } else { - e.push(ov); + e.push(( + match (layer, resized) { + (0, _) => Entry::Override, + (_, false) => Entry::Derived, + (_, true) => Entry::Stale, + }, + ov, + )); } } } @@ -1105,11 +1276,21 @@ impl<'a> Mapper<'a> { o.insert("style".into(), json!({"boundVariables": text_bound})); } let mut modes = Obj::new(); + let mut lost = false; for e in v.get("variableModeBySetMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() { - if let (Some(set), Some(mode)) = (e.get("variableSetID").and_then(ref_guid), e.get("variableModeID").and_then(guid)) { - modes.insert(set, json!(mode)); + let (Some(set), Some(mode)) = (e.get("variableSetID"), e.get("variableModeID").and_then(guid)) else { continue }; + match self.resource_id(set) { + Some(id) => { + modes.insert(id, json!(mode)); + } + // A mode of a collection neither named by guid nor resolvable in the file. + None if set.get("guid").is_some() || set.get("assetRef").is_some() => lost = true, + None => {} } } + if lost { + self.warn("variable modes of collections that aren't in the file were skipped"); + } if !modes.is_empty() { o.insert("explicitVariableModes".into(), Value::Object(modes)); } @@ -1128,7 +1309,7 @@ impl<'a> Mapper<'a> { found.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); let mut out = Vec::new(); for (_, id, n) in found { - let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, 0) else { break }; + let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, None, 0) else { break }; let name = n.get("name").and_then(Kv::str).unwrap_or("Style"); let desc = n.get("styleDescription").or_else(|| n.get("description")).and_then(Kv::str).unwrap_or(""); let ty = n.get("styleType").and_then(Kv::str).unwrap_or(""); @@ -1174,7 +1355,7 @@ impl<'a> Mapper<'a> { let mut variable_order = Vec::new(); let mut variables = Obj::new(); for (id, n) in of_type("VARIABLE", "sortPosition") { - let Some(set) = n.get("variableSetID").and_then(ref_guid).filter(|s| collections.contains_key(s)) else { continue }; + let Some(set) = n.get("variableSetID").and_then(|s| self.resource_id(s)).filter(|s| collections.contains_key(s)) else { continue }; let ty = n.get("variableResolvedType").and_then(Kv::str).unwrap_or(""); if !matches!(ty, "BOOLEAN" | "FLOAT" | "STRING" | "COLOR") { continue; @@ -1387,6 +1568,11 @@ fn child_layout(o: &mut Obj, v: &View, parent: Option<&View>) { } } +/// Whether a paint's colour or one of its gradient stops is bound to a variable. +fn paint_bound(p: &Kv) -> bool { + alias(p.get("colorVar")).is_some() || p.get("stopsVar").map(Kv::list).unwrap_or_default().iter().any(|s| alias(s.get("colorVar")).is_some()) +} + fn paint(p: &Kv) -> Option { let ty = p.get("type").and_then(Kv::str)?; let mut o = Obj::new(); diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 541739c7..19c4fc15 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -32,18 +32,20 @@ enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQU enum NavigationType NAVIGATE OVERLAY enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE enum EasingType IN_CUBIC OUT_CUBIC +enum ConstraintType MIN CENTER MAX STRETCH SCALE struct GUID uint sessionID, uint localID struct Color float r, float g, float b, float a struct Vector float x, float y struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 struct ColorStop Color color, float position message Image byte[] hash, string name -message VariableID GUID guid +message AssetRef string key, string version +message VariableID GUID guid, AssetRef assetRef message PropRefValue GUID defId message AnyValue bool boolValue, float floatValue, VariableID alias, Color colorValue, PropRefValue propRefValue, TextData textDataValue, string textValue, Expression expressionValue message Expression ExpressionFunction expressionFunction, VariableData[] expressionArguments message VariableTarget VariableID id -message VariableSetID GUID guid +message VariableSetID GUID guid, AssetRef assetRef message ConditionalActions Action[] actions, VariableData condition message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition @@ -70,7 +72,7 @@ message Event InteractionType interactionType message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions message Interaction Event event, Action[] actions message StartingPoint string name, string position -message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version message Blob byte[] bytes message Message NodeChange[] nodeChanges, Blob[] blobs "; @@ -665,6 +667,209 @@ fn instances_inside_their_own_main_stop() { assert!(doc.nodes().any(|n| n.name == "Self")); } +/// A collection from a library is named by its asset key, not a guid — also in the explicit mode +/// map of the frames, components and instances set it on, and in the variables' collection link. +#[test] +fn modes_of_collections_named_by_asset_import() { + let modes = arr(vec![ + o(vec![("id", g(90)), ("name", s("Dark")), ("sortPosition", s("!"))]), + o(vec![("id", g(91)), ("name", s("Light")), ("sortPosition", s("\""))]), + ]); + let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]); + let values = o(vec![( + "entries", + arr(vec![ + o(vec![("modeID", g(90)), ("variableData", color_value(rgb(0.0, 0.0, 0.0)))]), + o(vec![("modeID", g(91)), ("variableData", color_value(rgb(1.0, 1.0, 1.0)))]), + ]), + )]); + let by_asset = |key: &'static str, version: Option<&'static str>| { + let mut r = vec![("key", s(key))]; + r.extend(version.map(|v| ("version", s(v)))); + o(vec![("assetRef", o(r))]) + }; + let mode_map = |set: Kv<'static>| o(vec![("entries", arr(vec![o(vec![("variableSetID", set), ("variableModeID", g(91))])]))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page A", vec![]), + node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + // The collection itself says the asset key its references name. + node(4, Some((2, "!")), "VARIABLE_SET", "Schema", vec![("variableSetModes", modes), ("key", s("schema-collection")), ("version", s("7"))]), + node( + 5, + Some((2, "\"")), + "VARIABLE", + "surface", + vec![("variableSetID", by_asset("schema-collection", Some("7"))), ("variableResolvedType", e("COLOR")), ("variableDataValues", values)], + ), + node( + 10, + Some((1, "!")), + "FRAME", + "Light section", + vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7"))))], + ), + node( + 20, + Some((1, "\"")), + "SYMBOL", + "Button", + vec![("size", xy(100.0, 40.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", None)))], + ), + node( + 21, + Some((1, "#")), + "INSTANCE", + "Button copy", + vec![ + ("size", xy(100.0, 40.0)), + ("symbolData", o(vec![("symbolID", g(20))])), + ("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7")))), + ], + ), + // One names a collection that isn't in the file: its mode is skipped, with a warning. + node(11, Some((1, "$")), "FRAME", "Gone", vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("gone", None)))]), + ]; + let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert_eq!(doc.variables.collections.len(), 1); + assert_eq!(doc.variables.variables.len(), 1); + assert_eq!(doc.variables.variables[0].collection, "1:4"); + for name in ["Light section", "Button", "Button copy"] { + let n = named(&doc, name); + assert_eq!(n.var_modes.get("1:4").map(String::as_str), Some("1:91"), "{name}"); + } + assert!(named(&doc, "Gone").var_modes.is_empty()); + assert!(warnings.iter().any(|w| w.contains("variable modes of collections that aren't in the file")), "{warnings:?}"); +} + +#[test] +fn resized_instances_lay_their_layers_out_by_their_constraints() { + let pin = |h: &'static str, v: &'static str| vec![("horizontalConstraint", e(h)), ("verticalConstraint", e(v))]; + let layer = |id, pos, ty, name, w, h, x, y, c: Vec<(&'static str, Kv<'static>)>| { + node(id, Some((5, pos)), ty, name, [vec![("size", xy(w, h)), ("transform", at(x, y))], c].concat()) + }; + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(5, Some((1, "!")), "SYMBOL", "Logo", vec![("size", xy(132.0, 22.0)), ("transform", at(0.0, 0.0))]), + layer(6, "!", "ROUNDED_RECTANGLE", "Mark", 132.0, 22.0, 0.0, 0.0, pin("SCALE", "SCALE")), + layer(7, "\"", "ROUNDED_RECTANGLE", "Badge", 32.0, 10.0, 100.0, 12.0, pin("MAX", "MAX")), + layer(8, "#", "ROUNDED_RECTANGLE", "Bar", 112.0, 2.0, 10.0, 0.0, pin("STRETCH", "MIN")), + // A group scales its layers with it. + layer(11, "$", "FRAME", "Wordmark", 66.0, 22.0, 66.0, 0.0, [pin("SCALE", "SCALE"), vec![("resizeToFit", Kv::Bool(true))]].concat()), + node(12, Some((11, "!")), "ROUNDED_RECTANGLE", "Letter", vec![("size", xy(66.0, 22.0)), ("transform", at(0.0, 0.0))]), + node( + 9, + Some((1, "\"")), + "INSTANCE", + "Small logo", + vec![("size", xy(90.0, 15.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(5))]))], + ), + // Derived layout the file gives wins over the constraints. + node( + 10, + Some((1, "#")), + "INSTANCE", + "Derived logo", + vec![ + ("size", xy(90.0, 15.0)), + ("transform", at(0.0, 200.0)), + ("symbolData", o(vec![("symbolID", g(5))])), + ("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(50.0, 5.0))])])), + ], + ), + // A size the instance overrides: the place still follows the constraints. + node( + 13, + Some((1, "$")), + "INSTANCE", + "Resized badge", + vec![ + ("size", xy(90.0, 15.0)), + ("transform", at(0.0, 300.0)), + ( + "symbolData", + o(vec![ + ("symbolID", g(5)), + ("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(7)]))])), ("size", xy(20.0, 10.0))])])), + ]), + ), + ], + ), + ]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + let box_of = |inst: &str, name: &str| { + let i = named(&doc, inst); + let n = i.children.iter().map(|c| doc.node(*c).unwrap()).find(|n| n.name == name).unwrap(); + let t = n.transform.translation(); + [t.x, t.y, n.width, n.height].map(|x| (x * 1e3).round() / 1e3) + }; + assert_eq!(box_of("Small logo", "Mark"), [0.0, 0.0, 90.0, 15.0]); + assert_eq!(box_of("Small logo", "Badge"), [58.0, 5.0, 32.0, 10.0]); + assert_eq!(box_of("Small logo", "Bar"), [10.0, 0.0, 70.0, 2.0]); + // The main's own, then the instances'. + let mut letters: Vec<[f64; 2]> = + doc.nodes().filter(|n| n.name == "Letter").map(|n| [n.width, n.height].map(|x| (x * 1e3).round() / 1e3)).collect(); + letters.sort_by(|a, b| a[0].total_cmp(&b[0])); + assert_eq!(letters, [[45.0, 15.0], [45.0, 15.0], [45.0, 15.0], [66.0, 22.0]]); + assert_eq!(box_of("Derived logo", "Mark")[2..], [50.0, 5.0]); + assert_eq!(box_of("Resized badge", "Badge"), [58.0, 5.0, 20.0, 10.0]); +} + +#[test] +fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { + let stretch = || vec![("horizontalConstraint", e("STRETCH")), ("verticalConstraint", e("MIN"))]; + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(20, Some((1, "!")), "SYMBOL", "Track", vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))]), + node(21, Some((20, "!")), "ROUNDED_RECTANGLE", "Fill", [vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))], stretch()].concat()), + node(30, Some((1, "\"")), "SYMBOL", "Slider", vec![("size", xy(200.0, 50.0)), ("transform", at(0.0, 100.0))]), + // The nested instance is 150 wide in its main, with the layout derived for that. + node( + 31, + Some((30, "!")), + "INSTANCE", + "Inner", + [ + vec![ + ("size", xy(150.0, 10.0)), + ("transform", at(0.0, 0.0)), + ("symbolData", o(vec![("symbolID", g(20))])), + ( + "derivedSymbolData", + arr(vec![o(vec![ + ("guidPath", o(vec![("guids", arr(vec![g(21)]))])), + ("size", xy(150.0, 10.0)), + ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), + ])]), + ), + ], + stretch(), + ] + .concat(), + ), + node( + 40, + Some((1, "#")), + "INSTANCE", + "Wide slider", + vec![("size", xy(300.0, 50.0)), ("transform", at(0.0, 200.0)), ("symbolData", o(vec![("symbolID", g(30))]))], + ), + ]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + let width = |outer: &str| { + let inner = doc.node(named(&doc, outer).children[0]).unwrap(); + (inner.width, doc.node(inner.children[0]).unwrap().width) + }; + // In its main the derived layout holds; stretched to 250 it no longer does. + assert_eq!(width("Slider"), (150.0, 150.0)); + assert_eq!(width("Wide slider"), (250.0, 250.0)); + // What else the derived data gives (the colour a variable resolves to, say) still holds. + let fill = doc.node(doc.node(named(&doc, "Wide slider").children[0]).unwrap().children[0]).unwrap(); + assert!(matches!(fill.fills.first().map(|p| &p.kind), Some(figcraft_doc::color::PaintKind::Solid { color }) if color.r == 1.0 && color.g == 0.0)); +} + #[path = "tail_tests.rs"] mod tail; diff --git a/crates/format/src/image_budget_tests.rs b/crates/format/src/image_budget_tests.rs new file mode 100644 index 00000000..58bb4da1 --- /dev/null +++ b/crates/format/src/image_budget_tests.rs @@ -0,0 +1,93 @@ +//! The images of a native file within a budget (#189): however many members each within the +//! per-member limit a file holds, the images read from it together stay within a byte and a count +//! budget. The document's own images are read first, and the ones past the budget are reported. + +use std::io::Write; + +use figcraft_doc::Document; + +use super::*; + +fn png(k: u8) -> Vec { + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([k, 7, 0, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A saved document with `n` images, and their hashes and total size. +fn file_with_images(n: u8) -> (Vec, Vec, u64) { + let mut d = Document::new("File"); + let mut hashes = Vec::new(); + let mut total = 0; + for k in 0..n { + let bytes = png(k); + total += bytes.len() as u64; + hashes.push(add_image(&mut d, bytes).unwrap().0); + } + (save_bytes(&d, None).unwrap(), hashes, total) +} + +/// `bytes` with one more member, `name` holding `data`. +fn with_member(bytes: &[u8], name: &str, data: &[u8]) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let names: Vec = z.file_names().map(str::to_string).collect(); + let mut buf = std::io::Cursor::new(Vec::new()); + let mut w = zip::ZipWriter::new(&mut buf); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for n in names { + let member = read_member(&mut z, &n, u64::MAX).unwrap(); + w.start_file(n, stored).unwrap(); + w.write_all(&member).unwrap(); + } + w.start_file(name, stored).unwrap(); + w.write_all(data).unwrap(); + w.finish().unwrap(); + buf.into_inner() +} + +#[test] +fn images_exactly_within_the_budget_all_load() { + let (bytes, hashes, total) = file_with_images(3); + let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: total }).unwrap(); + assert!(loaded.issues.is_empty(), "{:?}", loaded.issues); + assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h))); +} + +#[test] +fn images_past_the_byte_budget_are_left_out_and_reported() { + let (bytes, hashes, total) = file_with_images(3); + let loaded = load_within(&bytes, ImageBudget { images: 100, bytes: total - 1 }).unwrap(); + let held: u64 = loaded.doc.images.values().map(|i| i.bytes.len() as u64).sum(); + assert!(held < total, "{held} bytes held"); + assert_eq!(loaded.doc.images.len(), 2); + let lost: Vec<&String> = hashes.iter().filter(|h| !loaded.doc.images.contains_key(*h)).collect(); + assert_eq!(lost.len(), 1); + assert!(loaded.issues.iter().any(|i| i.contains(lost[0].as_str()) && i.contains("together")), "{:?}", loaded.issues); +} + +#[test] +fn images_past_the_count_budget_are_left_out_and_reported() { + let (bytes, _, _) = file_with_images(4); + let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: u64::MAX }).unwrap(); + assert_eq!(loaded.doc.images.len(), 3); + assert!(loaded.issues.iter().any(|i| i.contains("more than 3 images")), "{:?}", loaded.issues); +} + +#[test] +fn the_documents_own_images_are_read_before_others() { + let (bytes, hashes, total) = file_with_images(2); + // An image the document doesn't list, named to come first in the archive's order. + let extra = with_member(&bytes, "images/0000", &png(99)); + let loaded = load_within(&extra, ImageBudget { images: 2, bytes: total }).unwrap(); + assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h)), "{:?}", loaded.issues); + assert!(!loaded.issues.iter().any(|i| hashes.iter().any(|h| i.contains(h.as_str()))), "{:?}", loaded.issues); +} + +#[test] +fn a_file_within_the_shipped_budget_loads_as_before() { + let (bytes, hashes, _) = file_with_images(3); + let loaded = load_bytes_with_versions(&bytes).unwrap(); + assert!(loaded.issues.is_empty(), "{:?}", loaded.issues); + assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h))); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 56798d14..f88416e2 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -47,6 +47,8 @@ pub const MAX_VERSION_DESCRIPTION: usize = 2000; /// Largest version index and total size of the version snapshots read from one file. const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024; const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024; +/// Most images, and image bytes together, read from one file (members past it are left out). +const IMAGE_BUDGET: ImageBudget = ImageBudget { images: 20_000, bytes: 4 * 1024 * 1024 * 1024 }; const BRANCH_INDEX: &str = "branches/index.json"; /// Most branches a file keeps: making more is refused, extra entries in a file are skipped. pub const MAX_BRANCHES: usize = 100; @@ -453,8 +455,20 @@ pub(crate) fn read_member(z: &mut zip::ZipArchive, name: &str Ok(data) } +/// How many images, and how many bytes of them together, one file may give. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ImageBudget { + pub images: usize, + pub bytes: u64, +} + /// Read a `.figcraft` archive (or bare JSON) with its version history. pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { + load_within(bytes, IMAGE_BUDGET) +} + +/// [`load_bytes_with_versions`] reading at most `budget` of images. +pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result { let trimmed = bytes.iter().position(|b| !b.is_ascii_whitespace()).and_then(|i| bytes.get(i)); if trimmed == Some(&b'{') { let text = std::str::from_utf8(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?; @@ -464,18 +478,7 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?; let text = String::from_utf8(read_member(&mut z, "document.json", MAX_MEMBER)?).map_err(|e| FormatError::Corrupt(e.to_string()))?; let (mut doc, mut issues, own_images) = parse_json(&text)?; - let names: Vec = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect(); - let mut images = BTreeMap::new(); - let mut unread = Vec::new(); - for name in names { - let hash = name.trim_start_matches("images/").to_string(); - match read_member(&mut z, &name, MAX_MEMBER) { - Ok(data) => { - images.insert(hash, image_data(data)); - } - Err(e) => unread.push((hash, e)), - } - } + let (images, unread) = read_images(&mut z, own_images.as_ref(), budget); report_lost_images(&mut issues, &unread, own_images.as_ref(), &images); match library::read(&mut z, &images, u64::MAX) { Ok(lib) => doc.published = lib.map(Arc::new), @@ -497,6 +500,41 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { Ok(Loaded { doc, issues, versions, branches }) } +/// The archive's `images/` members by hash, within `budget`, and the ones that couldn't be read +/// with why. The document's own images (`own`) are read first, so a file past the budget loses +/// the images only its history uses before its own. +fn read_images( + z: &mut zip::ZipArchive, + own: Option<&BTreeSet>, + budget: ImageBudget, +) -> (BTreeMap, Vec<(String, FormatError)>) { + let mut hashes: Vec = z.file_names().filter_map(|n| n.strip_prefix("images/")).map(str::to_string).collect(); + hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h))); + let mut images = BTreeMap::new(); + let mut unread = Vec::new(); + let mut left = budget.bytes; + for (i, hash) in hashes.into_iter().enumerate() { + let name = format!("images/{hash}"); + if i >= budget.images { + unread.push((hash, FormatError::Corrupt(format!("the file has more than {} images", budget.images)))); + continue; + } + if z.by_name(&name).is_ok_and(|f| f.size() > left) { + let mb = budget.bytes / (1024 * 1024); + unread.push((hash, FormatError::Corrupt(format!("the file's images together pass {mb} MB")))); + continue; + } + match read_member(z, &name, left.min(MAX_MEMBER)) { + Ok(data) => { + left = left.saturating_sub(data.len() as u64); + images.insert(hash, image_data(data)); + } + Err(e) => unread.push((hash, e)), + } + } + (images, unread) +} + /// Most lost images reported one by one (the rest as a count). const MAX_IMAGE_ISSUES: usize = 20; @@ -712,6 +750,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec), Form #[cfg(test)] mod branch_tests; #[cfg(test)] +mod image_budget_tests; +#[cfg(test)] mod image_tests; #[cfg(test)] mod large_tests; diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 5fbb368d..7b97d215 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -126,7 +126,10 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle { } if let Some(h) = s.get("hyperlink") { match (text(h, "type"), text(h, "url")) { - (Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(super::props::clip_str(url).to_string()), + // The destination is data, not a label: `clip_str` is for names that go into warnings, + // and clipping a URL changed it into a different, working-looking one without a word + // (#459). The FIG importer already keeps it whole. + (Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(url.to_string()), _ => {} } } diff --git a/crates/format/tests/import_link_contract.rs b/crates/format/tests/import_link_contract.rs new file mode 100644 index 00000000..30a3408a --- /dev/null +++ b/crates/format/tests/import_link_contract.rs @@ -0,0 +1,57 @@ +//! A text hyperlink's destination survives a REST import whole, however long it is (#459). +//! +//! The assertions are the reporter's; the helper returns its findings instead of unwrapping, so +//! that the crate's `unwrap_used`/`panic` lints hold outside a `#[test]` function. + +use figcraft_doc::NodeKind; +use figcraft_format::rest; +use serde_json::json; + +/// The link of the imported text, and the warnings the import reported. `None` for a link means +/// the layer was missing, was not text, or carried no link at all. +fn imported_link(length: usize, override_style: bool) -> (String, Option, Vec) { + let url = format!("https://example.com/{}", "a".repeat(length - 20)); + assert_eq!(url.len(), length); + let mut text = json!({"id":"1:2","type":"TEXT","name":"Link","characters":"A","relativeTransform":[[1,0,0],[0,1,0]]}); + let style = json!({"hyperlink":{"type":"URL","url":url}}); + if override_style { + text["characterStyleOverrides"] = json!([1]); + text["styleOverrideTable"] = json!({"1":style}); + } else { + text["style"] = style; + } + let input = json!({"document":{"type":"DOCUMENT","children":[{"id":"1:1","type":"CANVAS","children":[text]}]}}); + assert!(rest::is_rest_json(&input)); + let Ok((doc, warnings)) = rest::import(&input, &rest::no_images) else { + return (url, None, vec!["import failed".into()]); + }; + let link = match doc.nodes().find(|n| n.name == "Link").map(|n| &n.kind) { + Some(NodeKind::Text(data)) if override_style => data.runs.first().and_then(|r| r.style.link.clone()), + Some(NodeKind::Text(data)) => data.style.link.clone(), + _ => None, + }; + (url, link, warnings) +} + +/// Every length in turn: the destination comes back exactly, and nothing is reported. +fn check(override_style: bool) { + let mut mismatches = Vec::new(); + for length in [200, 201, 240] { + let (url, link, warnings) = imported_link(length, override_style); + assert!(warnings.is_empty(), "import warned at {length}: {warnings:?}"); + if link.as_deref() != Some(url.as_str()) { + mismatches.push((length, link.map_or(0, |l| l.len()))); + } + } + assert!(mismatches.is_empty(), "URLs changed, (input length, output length): {mismatches:?}"); +} + +#[test] +fn base_hyperlink_preserves_url() { + check(false); +} + +#[test] +fn override_hyperlink_preserves_url() { + check(true); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 9f4d5b8f..49fd272f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -128,7 +128,7 @@ pub fn tool_definitions() -> Vec { tool( "batch", "Run commands as one step", - "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. Note: later commands cannot reference ids created by earlier ones in the same batch (ids are only known afterwards), so create containers first, then batch the edits.", + "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use a layer an earlier step created: anywhere in its params, {\"$step\": n} stands for the `id` in step n's result (zero-based; {\"$step\": n, \"field\": \"…\"} picks another field). Example: [{\"command\":\"node.create\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$step\":0}}}] builds a frame and a text inside it in one call.", obj( json!({ "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}}), &["command"])}, @@ -665,8 +665,19 @@ fn screenshot(b: &mut dyn Backend, a: &Map) -> Result