diff --git a/Cargo.lock b/Cargo.lock index 05adf9fca..889b53f7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2100,6 +2100,7 @@ dependencies = [ "egui", "figcraft-collab-net", "figcraft-engine", + "figcraft-format", "figcraft-text", "figcraft-ui-egui", "js-sys", diff --git a/ROADMAP.md b/ROADMAP.md index 61df4900c..194bbe88d 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-30 · **Change:** minor (`comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); `comment.list {mentions}` lists only the threads that @mention a name (#1415); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -356,7 +356,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 2026-10-30 | minor | M9, #1415 (the rest): `comment.people` lists every name a message @mentions besides those who wrote or reacted (once each in any case, a known name spelt as the file spells it, never an email address), so a name that has only been tagged shows up. `comment.next` takes the `mentions` filter and, like `comment.open`, returns the thread as `comment.list` lists it, with each message's `mentions`; `comment.prompt`'s bounded `comments` carry each thread's mentions (at most 32, names cut to 80 characters). One mention helper serves them all. Numbers unchanged. Tests: `comment_mentions_tests`. | +| 2026-10-30 | minor | M1, #1390: Preferences ▸ Scroll wheel zoom (`pref.scrollWheelZoom`, off by default, kept with the preferences). On, a mouse wheel zooms the canvas about the pointer at the rate Cmd/Ctrl+wheel zooms otherwise, and Cmd/Ctrl+wheel scrolls; Shift+wheel still scrolls sideways, and trackpad scrolls, pinches and touch screens are unchanged. A wheel is told from a trackpad by what the platform reports: macOS names the device; elsewhere a wheel scrolls by lines and a trackpad by pixels, so a mouse whose wheel reports pixels (Chromium-based browsers) keeps scrolling, and a trackpad that reports lines (some X11 setups) zooms. Numbers unchanged. Tests: `wheel_zoom_tests`. | | 2026-10-30 | minor | M9, part of #1415: `comment.list` takes `mentions` (a name, any case, a leading `@` allowed) and lists only the threads where a message, the first or a reply, @mentions it, combined with the other filters (`{"mentions": "Claude", "filter": "unread"}`). It matches the names each message's `mentions` reports, by the same parser the thread view highlights with (`figcraft_doc::mentions`), so `@Ada Lovelace` is Ada Lovelace, not Ada, and an email address mentions nobody; the name counts as one of the file's people, so an unknown name can be several words. Numbers unchanged. Tests: `comment_mentions_tests`. | +| 2026-10-30 | minor | M8, #1123: a safety net for internal errors in the browser. wasm32 can't catch a panic (stable Rust has no unwinding std for it), so there the command guard and the frame guard do nothing and a panic stopped the page, losing every unsaved change. The page now follows its files with unsaved changes after every frame (shared copies, taken again only when a file changed); its panic hook writes them to the page's local storage as `.figcraft` copies, as many as fit (browsers give a page about 5 MB), and says on the page that it stopped and what reloading brings back; the next page opens them again as unsaved files (`file.recover` also takes the copy as base64 `data`). Numbers unchanged. Tests: `recovery_tests` (figcraft-web). | | 2026-10-30 | minor | M8, #1104: boolean operations had no input cap, so a crafted file could keep the sweep busy for unbounded time and memory (any number of segments, crossing as often as they like, and a fold re-sweeping its growing result for every operand). Each sweep now takes at most 250 000 segments and 2 million possible crossings (pairs of segments whose bounds overlap, counted exactly in O(n log n)), and a fold at most 4 million segments swept over all its steps; past them the operation is refused with `TooComplex` ("the shapes are too complex to combine") before it runs. The example files' boolean groups sweep at most 55 segments and 155 possible crossings per step. A boolean group past the caps draws without its result, with a warning, once, like layers past the render's memory budget; the refusal is remembered, so redrawing it costs nothing; Flatten refuses it instead of dropping it. Numbers unchanged. Tests: `budget_tests` (pathops), `boolean_budget_tests` (render), `boolean_cap_tests` (engine). | | 2026-10-30 | minor | M0.9, #1396: `figcraft-cli` parses its arguments with clap, following RFC-0012 (craft-contrib), so help comes from the same definitions the parser uses and bad arguments fail before any work starts. Changed syntax: an option given twice is a usage error (it used to take the last value; the automation roots still repeat), a value starting with `-` needs `=` (`--filter=--json`), `--o` for `-o` and the `version` subcommand are gone (`--version` stays), `--format` is checked before the file is opened, help and usage errors are clap's text (`Usage: figcraft-cli …`, `error: …` with a hint instead of the whole usage). Every subcommand, flag, value form and exit code is otherwise unchanged. Tests: `args::tests` (each documented form, every usage error, every subcommand's help) and the CLI integration tests. Numbers unchanged | | 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged | diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 384e3579b..90e76c698 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -15,9 +15,11 @@ path = "src/main.rs" [dependencies] figcraft-engine = { workspace = true } +figcraft-format = { workspace = true } figcraft-ui-egui = { workspace = true } figcraft-collab-net = { workspace = true } figcraft-text = { workspace = true } +base64 = { workspace = true } egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true } @@ -26,7 +28,6 @@ serde_json = { 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" -base64 = { workspace = true } js-sys = "0.3" wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] } diff --git a/apps/figcraft-web/index.html b/apps/figcraft-web/index.html index 1915a9a20..7bcf5d382 100644 --- a/apps/figcraft-web/index.html +++ b/apps/figcraft-web/index.html @@ -10,9 +10,13 @@ #figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; } #figcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #6b6b6b; font: 14px system-ui, sans-serif; pointer-events: none; } + /* Shown once the page stopped on an internal error (web.rs: show_stopped). */ + #figcraft_stopped { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box; + padding: 16px; text-align: center; color: #1e1e1e; background: rgba(245, 245, 245, 0.92); font: 14px/1.5 system-ui, sans-serif; } @media (prefers-color-scheme: dark) { html, body { background: #2c2c2c; } #figcraft_loading { color: #b8b8b8; } + #figcraft_stopped { color: #f0f0f0; background: rgba(44, 44, 44, 0.92); } } diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index f9c0fb0a5..4f38d787e 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -9,6 +9,8 @@ //! - no native file dialogs yet: the session starts with one new document; //! - links open in a new browser tab; //! - of the preferences, the page keeps only your name across reloads (`kept.rs`); +//! - an internal error (a panic) can't be caught, so it stops the page: the files with unsaved +//! changes are kept in the page's storage and open again on the next reload (`recovery.rs`); //! - fonts: the built-in ones, and on request (the font menu's "Use fonts installed on this //! computer…", Chromium's Local Font Access) the computer's, each read when text uses it //! (`local_fonts.rs`); @@ -37,6 +39,10 @@ mod query; #[cfg(test)] mod query_tests; #[cfg(any(target_arch = "wasm32", test))] +mod recovery; +#[cfg(test)] +mod recovery_tests; +#[cfg(any(target_arch = "wasm32", test))] mod saved_files; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/figcraft-web/src/recovery.rs b/apps/figcraft-web/src/recovery.rs new file mode 100644 index 000000000..48e5bb4d1 --- /dev/null +++ b/apps/figcraft-web/src/recovery.rs @@ -0,0 +1,85 @@ +//! Recovery copies in the browser. The browser build can't catch a panic (wasm32 always aborts), +//! so a command or frame that panics ends the page instead of failing as on the desktop. The page +//! follows its files with unsaved changes after every frame ([`Unsaved::follow`]: cheap shared +//! copies, taken only when a file changed); the panic hook writes them to the page's local storage +//! ([`store`]) before the page stops, and the next page opens them again as unsaved files +//! ([`restore`], `file.recover`), as the desktop app does with its recovery copies. + +use std::sync::Arc; + +use base64::Engine as _; +use figcraft_engine::branches::{self, needs_copy, stamp}; +use figcraft_engine::doc::Document; +use figcraft_engine::{DocState, Session}; +use figcraft_format::{Branch, Version}; +use serde_json::json; + +/// A file with unsaved changes as of the last frame. +struct Copy { + uid: u64, + /// [`stamp`]: the revisions this copy is of. + stamp: Vec, + doc: Arc, + versions: Vec, + branches: Vec, +} + +impl Copy { + fn of(s: &Session, d: &DocState) -> Self { + let (versions, branches) = (d.versions.clone(), branches::current(s, d.uid)); + Self { uid: d.uid, stamp: stamp(s, d), doc: d.document_itself().clone(), versions, branches } + } +} + +/// The page's files with unsaved changes, as of the last frame. +#[derive(Default)] +pub struct Unsaved(Vec); + +impl Unsaved { + /// Catch up with `s`: a file that changed is taken again, the others are kept as they are. + pub fn follow(&mut self, s: &Session) { + let mut before = std::mem::take(&mut self.0); + for d in s.docs.iter().filter(|d| needs_copy(s, d)) { + let now = stamp(s, d); + let copy = match before.iter().position(|c| c.uid == d.uid && c.stamp == now) { + Some(i) => before.swap_remove(i), + None => Copy::of(s, d), + }; + self.0.push(copy); + } + } + + /// How many files have unsaved changes. + pub fn count(&self) -> usize { + self.0.len() + } + + /// Each file as a saved `.figcraft` file in base64 (one that can't be written is left out). + pub fn files(&self) -> Vec { + let b64 = base64::engine::general_purpose::STANDARD; + self.0 + .iter() + .filter_map(|c| figcraft_format::save_bytes_with_history(&c.doc, None, &c.versions, &c.branches).ok().map(|b| b64.encode(b))) + .collect() + } +} + +/// Keep as many of `files` as the storage takes, in order: `put` stores the list's JSON and says +/// whether it fitted (a refused write leaves what was stored before). Returns how many were kept. +pub fn store(files: Vec, mut put: impl FnMut(&str) -> bool) -> usize { + let mut kept = Vec::new(); + for f in files { + kept.push(f); + if !put(&json!(kept).to_string()) { + kept.pop(); + } + } + kept.len() +} + +/// Open the copies the last page kept (`kept`: what [`store`] stored, if anything) as files with +/// unsaved changes. Returns how many came back; junk in the storage is skipped. +pub fn restore(s: &mut Session, kept: Option) -> usize { + let files: Vec = kept.and_then(|k| serde_json::from_str(&k).ok()).unwrap_or_default(); + files.iter().filter(|f| s.execute("file.recover", &json!({"data": f})).is_ok()).count() +} diff --git a/apps/figcraft-web/src/recovery_tests.rs b/apps/figcraft-web/src/recovery_tests.rs new file mode 100644 index 000000000..1073ad2e1 --- /dev/null +++ b/apps/figcraft-web/src/recovery_tests.rs @@ -0,0 +1,98 @@ +//! Recovery copies in the browser: what a page kept when it stopped opens again, unsaved, on the +//! next page. + +use figcraft_engine::Session; +use figcraft_ui_egui::browser_stopped; +use serde_json::json; + +use crate::recovery::{Unsaved, restore, store}; + +fn edited(name: &str) -> Session { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("file.rename", &json!({"name": name})).unwrap(); + s +} + +/// The page's copies, stored as the panic hook stores them. +fn stored(u: &Unsaved) -> Option { + let mut out = None; + let kept = store(u.files(), |v| { + out = Some(v.to_string()); + true + }); + assert_eq!(kept, u.count()); + out +} + +#[test] +fn a_stopped_pages_unsaved_files_open_again_unsaved() { + let s = edited("Poster"); + let mut u = Unsaved::default(); + u.follow(&s); + assert_eq!(u.count(), 1); + let mut next = Session::new(); + let before = next.docs.len(); + assert_eq!(restore(&mut next, stored(&u)), 1); + assert_eq!(next.docs.len(), before + 1); + let d = next.ds().unwrap(); + assert_eq!(d.doc.name, "Poster"); + assert!(d.is_dirty(), "a recovered file has unsaved changes"); + assert!(d.path.is_none(), "saving it asks where"); +} + +#[test] +fn the_latest_edit_is_kept_and_a_saved_file_is_not() { + let mut s = edited("First"); + let mut u = Unsaved::default(); + u.follow(&s); + u.follow(&s); + assert_eq!(u.count(), 1, "following again without a change keeps the one copy"); + s.execute("file.rename", &json!({"name": "Later"})).unwrap(); + u.follow(&s); + let mut next = Session::new(); + assert_eq!(restore(&mut next, stored(&u)), 1); + assert_eq!(next.ds().unwrap().doc.name, "Later"); + s.execute("file.markSaved", &json!({})).unwrap(); + u.follow(&s); + assert_eq!(u.count(), 0); + assert!(u.files().is_empty()); +} + +#[test] +fn what_the_storage_has_no_room_for_is_left_out() { + let files = vec!["a".repeat(10), "b".repeat(100), "c".repeat(10)]; + let mut kept = String::new(); + let n = store(files, |v| { + let fits = v.len() < 60; + if fits { + kept = v.to_string(); + } + fits + }); + assert_eq!(n, 2); + assert_eq!(kept, json!(["a".repeat(10), "c".repeat(10)]).to_string()); + assert_eq!(store(vec!["x".into()], |_| false), 0); +} + +#[test] +fn junk_in_the_storage_opens_nothing() { + let mut s = Session::new(); + let before = s.docs.len(); + for junk in [None, Some("not json"), Some("{}"), Some(r#"["!!"]"#), Some(r#"["aGVsbG8="]"#), Some("[1, null]")] { + assert_eq!(restore(&mut s, junk.map(String::from)), 0, "{junk:?}"); + } + assert_eq!(s.docs.len(), before); +} + +#[test] +fn the_stopped_page_says_what_was_kept() { + let says = |unsaved, kept, what: &str| { + let text = browser_stopped(unsaved, kept); + assert!(text.starts_with("FigCraft stopped because of an internal error. ") && text.contains(what), "{text}"); + }; + says(0, 0, "start again"); + says(2, 2, "get your unsaved files back"); + says(2, 0, "too large for the browser to keep"); + says(3, 1, "the browser had room for"); +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index a3e4d9305..e8e7f54ad 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -6,11 +6,23 @@ use wasm_bindgen::JsCast as _; const CANVAS_ID: &str = "figcraft_canvas"; const LOADING_ID: &str = "figcraft_loading"; +/// What the page shows once it stopped on an internal error (styled in `index.html`). +const STOPPED_ID: &str = "figcraft_stopped"; /// The local storage key your name is kept under ([`crate::kept`]). const NAME_KEY: &str = "figcraft.userName"; +/// The local storage key the recovery copies are kept under ([`crate::recovery`]): a JSON list of +/// base64 `.figcraft` files. +const RECOVERY_KEY: &str = "figcraft.recovery"; + +thread_local! { + /// The files with unsaved changes as of the last frame, for the panic hook ([`crate::recovery`]). + static UNSAVED: std::cell::RefCell = std::cell::RefCell::default(); +} pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok(); + // Before the runner's own hook, which logs the panic and then calls this one. + keep_unsaved_on_panic(); wasm_bindgen_futures::spawn_local(async { let Some(document) = web_sys::window().and_then(|w| w.document()) else { log::error!("no document"); @@ -39,11 +51,13 @@ pub fn start() { let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten()); let mut session = Session::new(); crate::kept::restore_name(&mut session, local_storage().and_then(|s| s.get_item(NAME_KEY).ok().flatten())); + let recovered = restore_unsaved(&mut session); // Live sessions: the browser joins sessions and publishes files to servers; // whatever arrives on the socket wakes the page for a frame. let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); + fig.note_recovered(recovered); // `#join=`: join that session, as opening an invite link does on the desktop. let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default(); if let Some(link) = crate::query::join_link(&hash) { @@ -70,6 +84,41 @@ pub fn start() { }); } +/// The browser can't catch a panic (wasm32 aborts), so the page ends with it: keep the files with +/// unsaved changes in the page's storage for the next page, and say so on the page. +fn keep_unsaved_on_panic() { + static ONCE: std::sync::Once = std::sync::Once::new(); + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + previous(info); + // Callbacks that run after the page stopped can panic again: the files are kept once. + ONCE.call_once(|| { + let (unsaved, files) = UNSAVED.with(|u| u.try_borrow().map(|u| (u.count(), u.files())).unwrap_or_default()); + let kept = local_storage().map_or(0, |s| crate::recovery::store(files, |v| s.set_item(RECOVERY_KEY, v).is_ok())); + show_stopped(&figcraft_ui_egui::browser_stopped(unsaved, kept)); + }); + })); +} + +/// Open the files the last page kept when it stopped; how many came back. +fn restore_unsaved(session: &mut Session) -> usize { + let Some(storage) = local_storage() else { return 0 }; + let kept = storage.get_item(RECOVERY_KEY).ok().flatten(); + // Gone before they open: a copy that stops the page again must not come back on every reload + // (once a frame ran, the files it opened are followed as any unsaved file). + let _ = storage.remove_item(RECOVERY_KEY); + crate::recovery::restore(session, kept) +} + +/// Say on the page that it stopped (the canvas stays as it was). +fn show_stopped(text: &str) { + let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; + let (Ok(el), Some(body)) = (document.create_element("div"), document.body()) else { return }; + el.set_id(STOPPED_ID); + el.set_text_content(Some(text)); + let _ = body.append_child(&el); +} + /// The page's local storage (`None` where the browser keeps none, e.g. storage turned off). fn local_storage() -> Option { web_sys::window().and_then(|w| w.local_storage().ok().flatten()) @@ -90,6 +139,8 @@ impl eframe::App for WebShell { /// still advance autosave, live and control deadlines without painting. `FigApp::ui` does /// not repeat it. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Every frame, hidden or not, starts here: what the last one left is what a panic keeps. + UNSAVED.with(|u| u.try_borrow_mut().map(|mut u| u.follow(&self.fig.session))).ok(); figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx)); } diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 605a04ca5..eb66c363a 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -25,6 +25,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use std::thread::JoinHandle; +use figcraft_engine::branches::{needs_copy, stamp}; use figcraft_engine::{DocState, Session}; use figcraft_format::{Branch, Version}; use serde_json::json; @@ -321,23 +322,6 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option { s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64() } -/// 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)) -} - -/// 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 { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index dd786a091..1c9402e9e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -33,7 +33,7 @@ use load::font_report; #[cfg(test)] pub(crate) use load::{installed_family, sketch_line}; pub use load::{load, load_bytes, load_path}; -pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write}; +pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variables_param, write}; pub use open::MAX_RECENT; #[cfg(test)] use open::append_pages; @@ -181,11 +181,16 @@ pub fn specs() -> Vec { spec!( "file.recover", "Recover file", - "{path} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}", + "{path | data (base64: the copy's bytes, as the browser keeps them)} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}", false, |s, p| { - let path = req_string("file.recover", p, "path")?; - s.docs.push(recovered(&path)?); + let ds = match string(p, "data") { + Some(d) => recovered_from( + &base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.recover", format!("`data`: {e}")))?, + )?, + None => recovered(&req_string("file.recover", p, "path")?)?, + }; + s.docs.push(ds); s.active = s.docs.len() - 1; Ok(json!({"index": s.active})) } diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 01b36edae..a8462f192 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -4,7 +4,13 @@ use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Resul /// The recovery copy at `path` as a document with unsaved changes and no path. pub(crate) fn recovered(path: &str) -> Result { - let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?; + recovered_from(&read(path)?) +} + +/// A recovery copy's bytes as a document with unsaved changes and no path (the browser keeps its +/// copies in the page's storage, not in files). +pub(crate) fn recovered_from(bytes: &[u8]) -> Result { + let loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved()) } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index a55ea2e2c..25304508f 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -369,6 +369,13 @@ pub fn specs() -> Vec { spec!("pref.renameDuplicates", "Rename duplicated layers", "{on?}", false, |s, p| pref(s, p, |x| &mut x.rename_duplicates)), 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.scrollWheelZoom", + "Scroll wheel zoom", + "{on?} a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls it (Shift+wheel still scrolls sideways; trackpads are unchanged) → {on}", + false, + |s, p| pref(s, p, |x| &mut x.scroll_wheel_zoom) + ), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), spec!( "pref.uiScale", diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index d77a1bfc5..8f96ae550 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -1,5 +1,9 @@ //! The last line of defence: a panic inside a command becomes an [`EngineError::Internal`] and //! the caller rolls the document back. It exists for bugs and doesn't replace `Result`. +//! +//! The browser build can't catch a panic (wasm32 always aborts, and stable Rust has no unwinding +//! std for it), so there a panicking command stops the page: its panic hook keeps the files with +//! unsaved changes for the next page instead (`apps/figcraft-web/src/recovery.rs`). use crate::{EngineError, Result}; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 89668a1a7..3c241ef6b 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -29,6 +29,9 @@ pub struct Prefs { pub highlight_on_hover: bool, pub number_keys_opacity: bool, pub show_dimensions: bool, + /// A mouse wheel zooms the canvas about the pointer, and Cmd/Ctrl+wheel scrolls it (off: the + /// other way round). Trackpads are unchanged. + pub scroll_wheel_zoom: bool, /// "system" by default; an explicit "light" or "dark" choice stays saved. pub theme: String, /// The widths of the main window's left and right panels (the interface keeps them within its @@ -122,6 +125,7 @@ impl Default for Prefs { highlight_on_hover: true, number_keys_opacity: true, show_dimensions: true, + scroll_wheel_zoom: false, theme: "system".into(), panel_widths: (240.0, 240.0), pages_height: None, diff --git a/crates/engine/src/sharing/branches.rs b/crates/engine/src/sharing/branches.rs index 4c9faa4ac..56f0ca3e0 100644 --- a/crates/engine/src/sharing/branches.rs +++ b/crates/engine/src/sharing/branches.rs @@ -180,6 +180,24 @@ pub fn current(s: &Session, uid: u64) -> Vec { branches } +/// The tabs showing branches of the file in tab `uid`. +pub fn tabs(s: &Session, uid: u64) -> impl Iterator { + s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid)) +} + +/// A file whose changes would be lost, so it needs a recovery copy: 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). +pub fn needs_copy(s: &Session, d: &DocState) -> bool { + d.branch_of.is_none() && (d.is_dirty() || tabs(s, d.uid).any(DocState::is_dirty)) +} + +/// The revisions a recovery 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. +pub fn stamp(s: &Session, d: &DocState) -> Vec { + std::iter::once(d.revision).chain(tabs(s, d.uid).map(|t| t.revision)).collect() +} + /// 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/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 8033bf4c0..07fb17fe9 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -81,7 +81,8 @@ pub use figcraft_geom::measure::finite_path as finite; /// Run the sweep library on `f`. Version 0.4 can panic on rare near-degenerate input (an internal /// `unwrap` while ordering curves, hit by some font outlines): that is bad input, not a crash. -// shortcut: no catch where panics abort (wasm32); drop this once a linesweeper release no longer panics there. +// shortcut: wasm32 can't catch it (panics abort there): the page stops and its panic hook keeps the +// unsaved files (apps/figcraft-web/src/recovery.rs); drop this once linesweeper no longer panics. fn sweep(f: impl FnOnce() -> Result) -> Result { std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(Err(PathOpsError::BadInput)) } diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index 8c9d26243..4429d0080 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -808,6 +808,7 @@ status = "draft — needs native review" "Main menu" = "القائمة الرئيسية" "Make a component" = "إنشاء مكوّن" "Make slot" = "إنشاء خانة محتوى" +"Scroll wheel zoom" = "التكبير بعجلة التمرير" "Slot name" = "اسم خانة المحتوى" "Mar" = "مارس" "Margin" = "الهامش" @@ -2192,6 +2193,7 @@ status = "draft — needs native review" "pref.language" = "لغة الواجهة" "pref.nudge" = "مقدار الإزاحة الطفيفة" "pref.numberKeysOpacity" = "استخدام مفاتيح الأرقام للعتامة" +"pref.scrollWheelZoom" = "التكبير بعجلة التمرير" "pref.publishServer" = "خادم المشاركة عبر الإنترنت" "pref.reduceMotion" = "تقليل الحركة" "pref.renameDuplicates" = "إعادة تسمية الطبقات المكررة" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 4d5f2a136..1bc17af4d 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -776,6 +776,7 @@ status = "draft — needs native review" "Main menu" = "প্রধান মেনু" "Make a component" = "একটি কম্পোনেন্ট তৈরি করুন" "Make slot" = "স্লট তৈরি করুন" +"Scroll wheel zoom" = "স্ক্রল হুইল দিয়ে জুম" "Slot name" = "স্লটের নাম" "Mar" = "মার্চ" "Margin" = "মার্জিন" @@ -1846,3 +1847,4 @@ status = "draft — needs native review" "tool.sticky" = "স্টিকি নোট" "widget.menu" = "উইজেটের মেনু" "widget.setText" = "উইজেটের ঘর সম্পাদনা" +"pref.scrollWheelZoom" = "স্ক্রল হুইল দিয়ে জুম" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 9d629382d..006bebd38 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -761,6 +761,7 @@ status = "draft — needs native review" "Main menu" = "Hlavní nabídka" "Make a component" = "Vytvořit komponentu" "Make slot" = "Vytvořit slot" +"Scroll wheel zoom" = "Přibližování kolečkem myši" "Slot name" = "Název slotu" "Mar" = "března" "Margin" = "Vnější okraj" @@ -2085,6 +2086,7 @@ status = "draft — needs native review" "pref.language" = "Jazyk rozhraní" "pref.nudge" = "Velikost posunu" "pref.numberKeysOpacity" = "Nastavovat krytí číselnými klávesami" +"pref.scrollWheelZoom" = "Přibližování kolečkem myši" "pref.publishServer" = "Server pro online sdílení" "pref.reduceMotion" = "Omezit pohyb" "pref.renameDuplicates" = "Přejmenovávat duplikované vrstvy" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index ce9de2b48..81c99baec 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -740,6 +740,7 @@ status = "draft — needs native review" "Main component deleted" = "Hauptkomponente gelöscht" "Main menu" = "Hauptmenü" "Make slot" = "Slot erstellen" +"Scroll wheel zoom" = "Mit dem Mausrad zoomen" "Slot name" = "Slot-Name" "Margin" = "Rand" "Mark all as read" = "Alle als gelesen markieren" @@ -2039,6 +2040,7 @@ status = "draft — needs native review" "pref.language" = "Sprache der Benutzeroberfläche" "pref.nudge" = "Schrittweite" "pref.numberKeysOpacity" = "Zifferntasten für Deckkraft verwenden" +"pref.scrollWheelZoom" = "Mit dem Mausrad zoomen" "pref.publishServer" = "Server für das Online-Teilen" "pref.reduceMotion" = "Bewegung reduzieren" "pref.renameDuplicates" = "Duplizierte Ebenen umbenennen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index f99c18aca..a4f3622ac 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -540,6 +540,7 @@ name = "English" "Fetching from the server…" = "Fetching from the server…" "FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {error}" "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." +"FigCraft stopped because of an internal error." = "FigCraft stopped because of an internal error." "FigCraft {version} is currently the newest version available." = "FigCraft {version} is currently the newest version available." "File" = "File" "File name" = "File name" @@ -813,6 +814,7 @@ name = "English" "Main menu" = "Main menu" "Make a component" = "Make a component" "Make slot" = "Make slot" +"Scroll wheel zoom" = "Scroll wheel zoom" "Slot name" = "Slot name" "Mar" = "Mar" "Margin" = "Margin" @@ -1147,6 +1149,9 @@ name = "English" "Regular expression" = "Regular expression" "Relative to" = "Relative to" "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." +"Reload the page to get back the unsaved files the browser had room for." = "Reload the page to get back the unsaved files the browser had room for." +"Reload the page to get your unsaved files back." = "Reload the page to get your unsaved files back." +"Reload the page to start again." = "Reload the page to start again." "Remove" = "Remove" "Remove all" = "Remove all" "Remove annotation" = "Remove annotation" @@ -1660,6 +1665,7 @@ name = "English" "Your copy stays open; the file stays on the server" = "Your copy stays open; the file stays on the server" "Your name" = "Your name" "Your system asks for reduced motion: animations stay off." = "Your system asks for reduced motion: animations stay off." +"Your unsaved files were too large for the browser to keep." = "Your unsaved files were too large for the browser to keep." "Z" = "Z" "Z to A" = "Z to A" "Zoom" = "Zoom" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6dd692483..0b2de28cb 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -32,6 +32,7 @@ status = "draft — needs native review" "Couldn't download the variables" = "No se pudieron descargar las variables" "Couldn't download {name}" = "No se pudo descargar {name}" "Relative to" = "Relativa a" +"Scroll wheel zoom" = "Zoom con la rueda del ratón" "Trigger layer" = "Capa que la abre" "Whole screen" = "Pantalla completa" @@ -2181,6 +2182,7 @@ status = "draft — needs native review" "pref.language" = "Idioma de la interfaz" "pref.nudge" = "Cantidad de desplazamiento" "pref.numberKeysOpacity" = "Usar teclas numéricas para la opacidad" +"pref.scrollWheelZoom" = "Zoom con la rueda del ratón" "pref.publishServer" = "Servidor para compartir en línea" "pref.reduceMotion" = "Reducir movimiento" "pref.renameDuplicates" = "Renombrar capas duplicadas" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 5c5c8edb2..c6ff654e3 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -778,6 +778,7 @@ status = "draft — needs native review" "Main menu" = "Päävalikko" "Make a component" = "Luo komponentti" "Make slot" = "Luo sisältöpaikka" +"Scroll wheel zoom" = "Zoomaus hiiren rullalla" "Slot name" = "Sisältöpaikan nimi" "Mar" = "maalisk." "Margin" = "Marginaali" @@ -2131,6 +2132,7 @@ status = "draft — needs native review" "pref.language" = "Käyttöliittymän kieli" "pref.nudge" = "Siirtoaskeleen koko" "pref.numberKeysOpacity" = "Säädä peittävyyttä numeronäppäimillä" +"pref.scrollWheelZoom" = "Zoomaus hiiren rullalla" "pref.publishServer" = "Verkkojakamisen palvelin" "pref.reduceMotion" = "Vähennä liikettä" "pref.renameDuplicates" = "Nimeä monistetut tasot uudelleen" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index f50150afa..732bc7bc2 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -778,6 +778,7 @@ status = "draft — needs native review" "Main menu" = "Menu principal" "Make a component" = "Créer un composant" "Make slot" = "Créer un emplacement" +"Scroll wheel zoom" = "Zoom avec la molette" "Slot name" = "Nom de l'emplacement" "Mar" = "mars" "Margin" = "Marge" @@ -2132,6 +2133,7 @@ status = "draft — needs native review" "pref.language" = "Langue de l’interface" "pref.nudge" = "Pas de décalage" "pref.numberKeysOpacity" = "Utiliser les touches numériques pour l’opacité" +"pref.scrollWheelZoom" = "Zoom avec la molette" "pref.publishServer" = "Serveur pour le partage en ligne" "pref.reduceMotion" = "Réduire les animations" "pref.renameDuplicates" = "Renommer les calques dupliqués" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 667644819..6c3b9a6cf 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -9,6 +9,7 @@ status = "draft — needs native review" [ui] "Relative to" = "Relatif terhadap" +"Scroll wheel zoom" = "Zoom dengan roda gulir" "Trigger layer" = "Layer pemicu" "Whole screen" = "Seluruh layar" @@ -2089,6 +2090,7 @@ status = "draft — needs native review" "pref.language" = "Bahasa antarmuka" "pref.nudge" = "Jarak geser" "pref.numberKeysOpacity" = "Gunakan tombol angka untuk opasitas" +"pref.scrollWheelZoom" = "Zoom dengan roda gulir" "pref.publishServer" = "Server untuk berbagi online" "pref.reduceMotion" = "Kurangi gerakan" "pref.renameDuplicates" = "Ganti nama layer duplikat" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index a4e65a876..7e3a80e2c 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -751,6 +751,7 @@ status = "draft — needs native review" "Main menu" = "Menu principale" "Make a component" = "Crea un componente" "Make slot" = "Crea slot" +"Scroll wheel zoom" = "Zoom con la rotella del mouse" "Slot name" = "Nome dello slot" "Mar" = "mar" "Margin" = "Margine" @@ -2045,6 +2046,7 @@ status = "draft — needs native review" "pref.language" = "Lingua dell'interfaccia" "pref.nudge" = "Entità dello spostamento" "pref.numberKeysOpacity" = "Usa i tasti numerici per l'opacità" +"pref.scrollWheelZoom" = "Zoom con la rotella del mouse" "pref.publishServer" = "Server per la condivisione online" "pref.reduceMotion" = "Riduci animazioni" "pref.renameDuplicates" = "Rinomina i livelli duplicati" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 316477973..63686d043 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -772,6 +772,7 @@ status = "draft — needs native review" "Main menu" = "メインメニュー" "Make a component" = "コンポーネントを作成" "Make slot" = "スロットにする" +"Scroll wheel zoom" = "スクロールホイールでズーム" "Slot name" = "スロット名" "Mar" = "3月" "Margin" = "マージン" @@ -2111,6 +2112,7 @@ status = "draft — needs native review" "pref.language" = "インターフェースの言語" "pref.nudge" = "微調整の量" "pref.numberKeysOpacity" = "数字キーで不透明度を変更" +"pref.scrollWheelZoom" = "スクロールホイールでズーム" "pref.publishServer" = "オンライン共有用のサーバー" "pref.reduceMotion" = "動きを減らす" "pref.renameDuplicates" = "複製したレイヤーの名前を変更" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 985c48451..2be767e54 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -785,6 +785,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Main menu" = "მთავარი მენიუ" "Make a component" = "შექმენით კომპონენტი" "Make slot" = "სლოტად გადაქცევა" +"Scroll wheel zoom" = "მასშტაბირება გადახვევის ბორბლით" "Slot name" = "სლოტის სახელი" "Mar" = "მარ" "Margin" = "ველი" @@ -2184,6 +2185,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "pref.language" = "ინტერფეისის ენა" "pref.nudge" = "მცირედ გადაწევის ბიჯი" "pref.numberKeysOpacity" = "გაუმჭვირვალობისთვის ციფრული კლავიშების გამოყენება" +"pref.scrollWheelZoom" = "მასშტაბირება გადახვევის ბორბლით" "pref.publishServer" = "ონლაინ გაზიარების სერვერი" "pref.reduceMotion" = "მოძრაობის შემცირება" "pref.renameDuplicates" = "დუბლირებული შრეების სახელის შეცვლა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index cd256ac06..4b2f4f194 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -736,6 +736,7 @@ status = "draft — needs native review" "Main component deleted" = "메인 컴포넌트가 삭제됨" "Main menu" = "메인 메뉴" "Make slot" = "슬롯으로 만들기" +"Scroll wheel zoom" = "스크롤 휠로 확대/축소" "Slot name" = "슬롯 이름" "Margin" = "여백" "Mark all as read" = "모두 읽음으로 표시" @@ -2145,6 +2146,7 @@ status = "draft — needs native review" "pref.language" = "인터페이스 언어" "pref.nudge" = "미세 이동 거리" "pref.numberKeysOpacity" = "숫자 키로 불투명도 변경" +"pref.scrollWheelZoom" = "스크롤 휠로 확대/축소" "pref.publishServer" = "온라인 공유용 서버" "pref.reduceMotion" = "동작 줄이기" "pref.renameDuplicates" = "복제한 레이어 이름 바꾸기" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 803ce53c6..4d3e91405 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -778,6 +778,7 @@ status = "draft — needs native review" "Main menu" = "Hoofdmenu" "Make a component" = "Maak een component" "Make slot" = "Slot maken" +"Scroll wheel zoom" = "Zoomen met scrollwiel" "Slot name" = "Slotnaam" "Mar" = "mrt." "Margin" = "Marge" @@ -2130,6 +2131,7 @@ status = "draft — needs native review" "pref.language" = "Interfacetaal" "pref.nudge" = "Stapafstand" "pref.numberKeysOpacity" = "Cijfertoetsen gebruiken voor dekking" +"pref.scrollWheelZoom" = "Zoomen met scrollwiel" "pref.publishServer" = "Server voor online delen" "pref.reduceMotion" = "Beweging verminderen" "pref.renameDuplicates" = "Gedupliceerde lagen hernoemen" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 0d4986cdd..ee0145e4c 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -780,6 +780,7 @@ name = "Polski" "Main menu" = "Menu główne" "Make a component" = "Utwórz komponent" "Make slot" = "Utwórz gniazdo (slot)" +"Scroll wheel zoom" = "Powiększanie kółkiem myszy" "Slot name" = "Nazwa gniazda" "Mar" = "Mar" "Margin" = "Margines" @@ -2143,6 +2144,7 @@ name = "Polski" "pref.language" = "Język interfejsu" "pref.nudge" = "Wartości przesunięcia (Nudge)" "pref.numberKeysOpacity" = "Klawisze numeryczne zmieniają krycie" +"pref.scrollWheelZoom" = "Powiększanie kółkiem myszy" "pref.publishServer" = "Serwer do udostępniania online" "pref.reduceMotion" = "Ogranicz animacje" "pref.renameDuplicates" = "Zmieniaj nazwy duplikatów" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 8825d657f..23d9fbfbe 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -735,6 +735,7 @@ status = "draft — needs native review" "Main component deleted" = "Componente principal excluído" "Main menu" = "Menu principal" "Make slot" = "Transformar em slot" +"Scroll wheel zoom" = "Zoom com a roda do mouse" "Slot name" = "Nome do slot" "Margin" = "Margem" "Mark all as read" = "Marcar tudo como lido" @@ -2142,6 +2143,7 @@ status = "draft — needs native review" "pref.language" = "Idioma da interface" "pref.nudge" = "Valor do deslocamento" "pref.numberKeysOpacity" = "Usar teclas numéricas para a opacidade" +"pref.scrollWheelZoom" = "Zoom com a roda do mouse" "pref.publishServer" = "Servidor para compartilhamento online" "pref.reduceMotion" = "Reduzir movimento" "pref.renameDuplicates" = "Renomear camadas duplicadas" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 0065e930a..6432b21c2 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -780,6 +780,7 @@ name = "Русский" "Main menu" = "Главное меню" "Make a component" = "Создать компонент" "Make slot" = "Сделать слотом" +"Scroll wheel zoom" = "Масштабирование колесом мыши" "Slot name" = "Имя слота" "Mar" = "мар." "Margin" = "Внешний отступ" @@ -2143,6 +2144,7 @@ name = "Русский" "pref.language" = "Язык интерфейса" "pref.nudge" = "Шаг сдвига" "pref.numberKeysOpacity" = "Задавать непрозрачность цифровыми клавишами" +"pref.scrollWheelZoom" = "Масштабирование колесом мыши" "pref.publishServer" = "Сервер для онлайн-публикации" "pref.reduceMotion" = "Уменьшить движение" "pref.renameDuplicates" = "Переименовывать дубликаты слоёв" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 714b1ecf5..09974e374 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -802,6 +802,7 @@ name = "Svenska" "Main menu" = "Huvudmeny" "Make a component" = "Skapa en komponent" "Make slot" = "Gör till plats" +"Scroll wheel zoom" = "Zooma med scrollhjulet" "Slot name" = "Platsens namn" "Mar" = "Mar" "Margin" = "Marginal" @@ -2188,6 +2189,7 @@ name = "Svenska" "pref.language" = "Gränssnittsspråk" "pref.nudge" = "Steglängd" "pref.numberKeysOpacity" = "Använd siffertangenterna för opacitet" +"pref.scrollWheelZoom" = "Zooma med scrollhjulet" "pref.publishServer" = "Server för delning online" "pref.reduceMotion" = "Minska rörelse" "pref.renameDuplicates" = "Byt namn på duplicerade lager" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index 730fb297b..92a52d7ff 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -774,6 +774,7 @@ status = "draft — needs native review" "Main menu" = "เมนูหลัก" "Make a component" = "สร้างคอมโพเนนต์" "Make slot" = "สร้างสล็อต" +"Scroll wheel zoom" = "ซูมด้วยล้อเลื่อนเมาส์" "Slot name" = "ชื่อสล็อต" "Mar" = "มี.ค." "Margin" = "ระยะขอบนอก" @@ -2107,6 +2108,7 @@ status = "draft — needs native review" "pref.language" = "ภาษาของอินเทอร์เฟซ" "pref.nudge" = "ระยะขยับ" "pref.numberKeysOpacity" = "ใช้ปุ่มตัวเลขกำหนดความทึบ" +"pref.scrollWheelZoom" = "ซูมด้วยล้อเลื่อนเมาส์" "pref.publishServer" = "เซิร์ฟเวอร์สำหรับการแชร์ออนไลน์" "pref.reduceMotion" = "ลดการเคลื่อนไหว" "pref.renameDuplicates" = "เปลี่ยนชื่อเลเยอร์ที่ทำสำเนา" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index dbb75d4c5..2b514b11f 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -765,6 +765,7 @@ name = "Türkçe" "Main menu" = "Ana menü" "Make a component" = "Bileşen yap" "Make slot" = "Yuva yap" +"Scroll wheel zoom" = "Kaydırma tekerleğiyle yakınlaştırma" "Slot name" = "Yuva adı" "Mar" = "Mar" "Margin" = "Kenar boşluğu" @@ -2089,6 +2090,7 @@ name = "Türkçe" "pref.language" = "Arayüz dili" "pref.nudge" = "Kaydırma miktarı" "pref.numberKeysOpacity" = "Opaklık için rakam tuşlarını kullan" +"pref.scrollWheelZoom" = "Kaydırma tekerleğiyle yakınlaştırma" "pref.publishServer" = "Çevrimiçi paylaşım sunucusu" "pref.reduceMotion" = "Hareketi azalt" "pref.renameDuplicates" = "Çoğaltılan katmanları yeniden adlandır" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 384f829e8..dab0c5c8e 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -752,6 +752,7 @@ status = "draft — needs native review" "Main menu" = "Trình đơn chính" "Make a component" = "Tạo thành phần" "Make slot" = "Tạo ô chứa" +"Scroll wheel zoom" = "Thu phóng bằng con lăn chuột" "Slot name" = "Tên ô chứa" "Mar" = "Thg 3" "Margin" = "Lề" @@ -2046,6 +2047,7 @@ status = "draft — needs native review" "pref.language" = "Ngôn ngữ giao diện" "pref.nudge" = "Mức dịch chuyển nhẹ" "pref.numberKeysOpacity" = "Dùng phím số để chỉnh độ mờ" +"pref.scrollWheelZoom" = "Thu phóng bằng con lăn chuột" "pref.publishServer" = "Máy chủ chia sẻ trực tuyến" "pref.reduceMotion" = "Giảm chuyển động" "pref.renameDuplicates" = "Đổi tên các lớp nhân bản" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 6953ce1cd..3cf26dbd0 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -741,6 +741,7 @@ status = "draft — needs native review" "Main component deleted" = "主组件已删除" "Main menu" = "主菜单" "Make slot" = "设为插槽" +"Scroll wheel zoom" = "滚轮缩放" "Slot name" = "插槽名称" "Margin" = "边距" "Mark all as read" = "全部标为已读" @@ -2042,6 +2043,7 @@ status = "draft — needs native review" "pref.language" = "界面语言" "pref.nudge" = "微移量" "pref.numberKeysOpacity" = "用数字键设置不透明度" +"pref.scrollWheelZoom" = "滚轮缩放" "pref.publishServer" = "在线分享所用的服务器" "pref.reduceMotion" = "减少动态效果" "pref.renameDuplicates" = "重命名副本图层" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3faa779f8..04e5a63f3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -81,6 +81,8 @@ pub struct CanvasState { /// The pen pressure last reported during the current press (pen and stylus touches; the /// winit backends and browsers that report it). pressure: Option, + /// The latest scroll came from a mouse wheel rather than a trackpad (Scroll wheel zoom). + wheel_is_mouse: bool, } impl Default for CanvasState { @@ -107,6 +109,7 @@ impl Default for CanvasState { hovering: false, preedit: None, pressure: None, + wheel_is_mouse: false, } } } @@ -367,5 +370,8 @@ mod builder_poly_tests; #[cfg(test)] mod outline_tests; +#[cfg(test)] +mod wheel_zoom_tests; + #[cfg(test)] mod zoom_preview_tests; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index e45e82026..9a24e90cf 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -25,6 +25,53 @@ fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods: true } +/// The wheel's scroll and the zoom for this frame. With Preferences ▸ Scroll wheel zoom on, a +/// mouse wheel zooms and Cmd/Ctrl+wheel scrolls ([`swap_wheel`]); Shift+wheel still scrolls +/// sideways, and trackpads, pinches and touch screens are left as they are. +fn wheel_zoom(app: &mut FigApp, ctx: &egui::Context, zoom: f32, shift: bool) -> (egui::Vec2, f32) { + let (wheel, latest, pinch) = ctx.input(|i| { + let latest = i.events.iter().rev().find_map(|e| match e { + egui::Event::MouseWheel { unit, source, .. } => Some(is_mouse_wheel(*source, *unit)), + _ => None, + }); + let pinch = i.multi_touch().is_some() || i.events.iter().any(|e| matches!(e, egui::Event::Zoom(_))); + (i.smooth_scroll_delta, latest, pinch) + }); + let speed = ctx.options(|o| o.input_options.scroll_zoom_speed); + // egui spreads a wheel step over a few frames: the kind holds until the next scroll event. + if let Some(mouse) = latest { + app.canvas.wheel_is_mouse = mouse; + } + if !app.session.prefs.scroll_wheel_zoom || !app.canvas.wheel_is_mouse || pinch { + return (wheel, zoom); + } + swap_wheel(wheel, zoom, shift, speed) +} + +/// Whether a scroll event came from a mouse wheel. Only macOS says which device scrolled; elsewhere +/// a wheel scrolls by lines and a trackpad by points, which is the best guess there is. +pub(crate) fn is_mouse_wheel(source: egui::MouseWheelSource, unit: egui::MouseWheelUnit) -> bool { + match source { + egui::MouseWheelSource::Wheel => true, + egui::MouseWheelSource::Unknown => unit != egui::MouseWheelUnit::Point, + _ => false, + } +} + +/// Scroll wheel zoom on a mouse wheel's frame: egui reads Cmd/Ctrl+wheel as `zoom`, which becomes +/// a scroll down (across with Shift: `ViewState::scroll`), and a plain +/// `wheel` scroll becomes a zoom at egui's own Cmd/Ctrl+wheel rate (`speed`). Shift+wheel is +/// left a sideways scroll. +pub(crate) fn swap_wheel(wheel: egui::Vec2, zoom: f32, shift: bool, speed: f32) -> (egui::Vec2, f32) { + if zoom > 0.0 && (zoom - 1.0).abs() > 1e-6 && speed > 0.0 { + return (egui::vec2(0.0, zoom.ln() / speed), 1.0); + } + if !shift && wheel != egui::Vec2::ZERO { + return (egui::Vec2::ZERO, (speed * (wheel.x + wheel.y)).exp()); + } + (wheel, zoom) +} + fn import_dropped(app: &mut FigApp, ctx: &egui::Context, hover_pos: Option) { let dropped = ctx.input(|i| i.raw.dropped_files.clone()); let mut refused = false; @@ -65,10 +112,12 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo } let ctx = ui.ctx().clone(); // Two fingers pan as well as pinch (a touch screen has no scroll wheel). - let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| { + let (mods, pan, zoom_delta, hover_pos, space) = ctx.input(|i| { let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |m| m.translation_delta); - (i.modifiers, i.smooth_scroll_delta + pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space)) + (i.modifiers, pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space)) }); + let (wheel, zoom_delta) = wheel_zoom(app, &ctx, zoom_delta, mods.shift); + let scroll = wheel + pan; let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false); let typing_elsewhere = crate::a11y::field_focused(&ctx); app.canvas.space_pan = space && !editing_text && !typing_elsewhere; @@ -103,7 +152,8 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo app.ui.pages_focus = None; app.ui.key_box = None; } - // Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans. + // Zoom (pinch, Cmd/Ctrl+scroll; a plain mouse wheel with Scroll wheel zoom) about the pointer; + // other scrolls pan. if resp.hovered() { if (zoom_delta - 1.0).abs() > 1e-4 { let anchor = app.to_page(pos); diff --git a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs new file mode 100644 index 000000000..5b196a4d4 --- /dev/null +++ b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs @@ -0,0 +1,128 @@ +//! Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and +//! Cmd/Ctrl+wheel scrolls it; Shift+wheel still scrolls sideways, and trackpads and pinches are +//! unchanged. Off (the default), a wheel scrolls and Cmd/Ctrl+wheel zooms. + +use egui::{Event, Modifiers, MouseWheelSource, MouseWheelUnit, Pos2, RawInput, Rect, TouchPhase, Vec2, vec2}; +use figcraft_engine::Session; +use serde_json::json; + +use super::input::{is_mouse_wheel, swap_wheel}; +use crate::{FigApp, NoServices}; + +/// The view's zoom, cx and cy. +type View = (f64, f64, f64); + +struct Canvas { + ctx: egui::Context, + app: FigApp, + time: f64, +} + +/// A notch of a mouse wheel (lines, as Windows and Linux report one) or a trackpad scroll (points). +fn wheel(unit: MouseWheelUnit, dy: f32, modifiers: Modifiers) -> Event { + Event::MouseWheel { unit, source: MouseWheelSource::Unknown, delta: vec2(0.0, dy), phase: TouchPhase::Move, modifiers } +} + +impl Canvas { + fn new(scroll_wheel_zoom: bool) -> Self { + let mut c = Self { ctx: egui::Context::default(), app: FigApp::new(Session::new(), Box::new(NoServices)), time: 0.0 }; + c.app.run("pref.scrollWheelZoom", json!({"on": scroll_wheel_zoom})); + for _ in 0..3 { + c.frame(vec![], Modifiers::NONE); + } + c + } + + /// The middle of the canvas (of the window until the canvas is laid out). + fn centre(&self) -> Pos2 { + let r = self.app.canvas.rect; + if r.is_positive() { r.center() } else { Pos2::new(720.0, 450.0) } + } + + fn frame(&mut self, mut events: Vec, modifiers: Modifiers) { + self.time += 1.0 / 60.0; + events.splice(0..0, [Event::ModifiersChanged(modifiers), Event::PointerMoved(self.centre())]); + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + let app = &mut self.app; + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + } + + /// `event` over the middle of the canvas, then frames until egui's smoothing settles; the + /// view's (zoom, cx, cy) before and after, and the page point under the pointer before and after. + fn gesture(&mut self, event: Event, modifiers: Modifiers) -> (View, View, (Vec2, Vec2)) { + let view = |c: &Self| c.app.session.ds().map(|d| (d.view.zoom, d.view.cx, d.view.cy)).unwrap(); + let under = |c: &Self| { + let p = c.app.to_page(c.centre()); + vec2(p.x as f32, p.y as f32) + }; + let (before, at) = (view(self), under(self)); + self.frame(vec![event], modifiers); + for _ in 0..40 { + self.frame(vec![], modifiers); + } + (before, view(self), (at, under(self))) + } +} + +#[test] +fn off_a_wheel_scrolls_and_cmd_or_ctrl_wheel_zooms() { + let mut c = Canvas::new(false); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::NONE), Modifiers::NONE); + assert_eq!(a.0, b.0, "no zoom"); + assert!(b.2 > a.2 + 1.0, "scrolled down: {a:?} → {b:?}"); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::COMMAND), Modifiers::COMMAND); + assert!(b.0 > a.0 * 1.05, "zoomed in: {a:?} → {b:?}"); +} + +#[test] +fn on_a_wheel_zooms_about_the_pointer_and_cmd_or_ctrl_wheel_scrolls() { + let mut c = Canvas::new(true); + let (a, b, (at, after)) = c.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE); + assert!(b.0 > a.0 * 1.05, "zoomed in: {a:?} → {b:?}"); + assert!((at - after).length() < 0.5, "about the pointer: {at:?} → {after:?}"); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::NONE), Modifiers::NONE); + assert!(b.0 < a.0 / 1.05, "zoomed out: {a:?} → {b:?}"); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::COMMAND), Modifiers::COMMAND); + assert!((b.0 - a.0).abs() < 1e-9 && b.2 > a.2 + 1.0 && (b.1 - a.1).abs() < 1e-9, "scrolled down: {a:?} → {b:?}"); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::SHIFT), Modifiers::SHIFT); + assert!((b.0 - a.0).abs() < 1e-9 && b.1 > a.1 + 1.0 && (b.2 - a.2).abs() < 1e-9, "scrolled sideways: {a:?} → {b:?}"); +} + +#[test] +fn on_trackpads_and_pinches_are_unchanged() { + let mut c = Canvas::new(true); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Point, -6.0, Modifiers::NONE), Modifiers::NONE); + assert!((b.0 - a.0).abs() < 1e-9 && b.2 > a.2, "two fingers pan: {a:?} → {b:?}"); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Point, 6.0, Modifiers::COMMAND), Modifiers::COMMAND); + assert!(b.0 > a.0, "Cmd/Ctrl+two fingers zoom: {a:?} → {b:?}"); + // A pinch right after a mouse wheel is still a pinch. + c.gesture(wheel(MouseWheelUnit::Line, 1.0, Modifiers::NONE), Modifiers::NONE); + let (a, b, _) = c.gesture(Event::Zoom(1.5), Modifiers::NONE); + assert!((b.0 / a.0 - 1.5).abs() < 1e-3, "pinch: {a:?} → {b:?}"); +} + +#[test] +fn which_scrolls_count_as_a_mouse_wheel() { + assert!(is_mouse_wheel(MouseWheelSource::Wheel, MouseWheelUnit::Point)); + assert!(is_mouse_wheel(MouseWheelSource::Unknown, MouseWheelUnit::Line)); + assert!(is_mouse_wheel(MouseWheelSource::Unknown, MouseWheelUnit::Page)); + assert!(!is_mouse_wheel(MouseWheelSource::Unknown, MouseWheelUnit::Point)); + assert!(!is_mouse_wheel(MouseWheelSource::Trackpad, MouseWheelUnit::Line)); + assert!(!is_mouse_wheel(MouseWheelSource::Momentum, MouseWheelUnit::Point)); +} + +#[test] +fn the_swap_keeps_egui_rates() { + let speed = 1.0 / 200.0; + // Cmd/Ctrl+wheel: egui's zoom back into the scroll it came from. + let (s, z) = swap_wheel(Vec2::ZERO, (speed * 40.0f32).exp(), false, speed); + assert!((s.y - 40.0).abs() < 1e-3 && s.x == 0.0 && z == 1.0); + // A plain wheel: the zoom egui gives Cmd/Ctrl+wheel. + let (s, z) = swap_wheel(vec2(0.0, 40.0), 1.0, false, speed); + assert!(s == Vec2::ZERO && (z - (speed * 40.0f32).exp()).abs() < 1e-6); + // Shift+wheel and nothing at all are left alone. + assert_eq!(swap_wheel(vec2(40.0, 0.0), 1.0, true, speed), (vec2(40.0, 0.0), 1.0)); + assert_eq!(swap_wheel(Vec2::ZERO, 1.0, false, speed), (Vec2::ZERO, 1.0)); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fe3bdb731..137f2ae6d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -238,6 +238,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { (tr("Rename duplicated layers"), prefs.rename_duplicates, "pref.renameDuplicates"), (tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"), (tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"), + (tr("Scroll wheel zoom"), prefs.scroll_wheel_zoom, "pref.scrollWheelZoom"), (tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"), ] { if crate::widgets::checkbox(ui, val, label) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 00ce8c009..7b535a0b6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -312,8 +312,8 @@ pub(crate) use clock::repaint_in; pub use clock::{AUTO_SAVE_SECS, AUTOSAVE_SECS, FrameClock, ToastClock}; pub(crate) use open::{drop_overlay, dropped_bytes, is_document, opens}; pub use services::{ - ContentsFuture, DialogFuture, MAX_CLIPBOARD_PNG_BYTES, NoServices, ReadFuture, Services, browser_can_read, egui_copy_png, egui_copy_text, - unreadable, + ContentsFuture, DialogFuture, MAX_CLIPBOARD_PNG_BYTES, NoServices, ReadFuture, Services, browser_can_read, browser_stopped, egui_copy_png, + egui_copy_text, unreadable, }; use services::{DialogDone, PickFuture, Picked, RepaintWaker}; pub use state::{ diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 9578b137c..058c005dd 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -124,6 +124,21 @@ pub fn unreadable(name: &str) -> String { i18n::trf("Couldn't read {name}.", &[("name", &name)]) } +/// What the page says once the browser build stopped on an internal error (in the browser a panic +/// ends the page): `unsaved` files had unsaved changes and `kept` of them fitted in its storage. +pub fn browser_stopped(unsaved: usize, kept: usize) -> String { + let next = if unsaved == 0 { + i18n::tr("Reload the page to start again.") + } else if kept >= unsaved { + i18n::tr("Reload the page to get your unsaved files back.") + } else if kept == 0 { + i18n::tr("Your unsaved files were too large for the browser to keep.") + } else { + i18n::tr("Reload the page to get back the unsaved files the browser had room for.") + }; + format!("{} {next}", i18n::tr("FigCraft stopped because of an internal error.")) +} + /// The largest file read in the browser: wasm has at most 4 GiB of memory, and a file's contents /// are held about three times on their way to `file.open` (the bytes, base64, decoded again). /// As large as one member of a `.figcraft` file may be. diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index a0875ee56..b9e7e997d 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -124,7 +124,8 @@ pub fn with_app(f: impl FnOnce(&mut FigApp) -> R) -> Option { } /// Last-resort guard around a frame: a panic becomes a message and recovery copies of unsaved -/// files, not a lost document. +/// files, not a lost document. (In the browser a panic can't be caught: the page's panic hook +/// keeps the copies instead, `apps/figcraft-web/src/recovery.rs`.) pub fn guard(app: &mut FigApp, ctx: &Context, f: impl FnOnce(&mut FigApp)) { // Started with automation roots, everything the window and the control channel do is confined. let roots = app.automation_roots.clone(); diff --git a/docs/architecture.md b/docs/architecture.md index 065a21326..9faed5479 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,7 +75,7 @@ content. Exports: PNG, JPG, WebP, SVG, PDF, code. There is no `.fig` reader yet Workspace lints deny `unwrap`, `expect`, `panic!` and friends in shipped code; `unsafe_code` is forbidden. `figcraft_engine::guard` turns a panicking command into an error and rolls the document -back; the desktop app also guards each UI frame. Junk-parameter sweeps run every command with bad +back; the desktop app also guards each UI frame. The browser build can't catch a panic (wasm32 aborts), so there a panic stops the page and its panic hook keeps the files with unsaved changes in local storage for the next page. Junk-parameter sweeps run every command with bad input. ## Revision history diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 121a6c8e8..1676054ce 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -44,13 +44,14 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. Draws with Direct3D 12 (FXC shaders) or OpenGL; when the graphics fail before the first frame it starts again on Vulkan, then OpenGL, then the power-saving GPU with lower limits (#965) | | Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | | FreeBSD | none | tarball | | -| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) | +| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps); an internal error stops the page (a panic can't be caught there), but its unsaved files are kept in the page's local storage (about 5 MB) and open again on reload (#1123) | | iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1123: the browser can't catch a panic, so an internal error stopped the page and lost its unsaved changes. Its panic hook now keeps the files with unsaved changes in the page's local storage (as many as fit, about 5 MB) and says so on the page; reloading opens them again as unsaved files. Numbers unchanged. | | 2026-10-30 | minor | M1, #1320: settings in portable mode. The settings folder now comes from craft-settings, the suite's rules: `FIGCRAFT_CONFIG_DIR` names a folder (set but empty: none, nothing read or written); a `portable` file next to the executable (next to the `.app` on macOS) puts the preferences, recovery copies and installed plugins in `config/` beside it; otherwise they stay in `FigCraft` in the platform folder, where every earlier version kept them, so nothing moves for an installed app. The Windows portable zip now ships the `portable` file. A portable folder without preferences yet starts with a copy of the platform folder's `state.json` (owner-only, the original left for an installed app), so a portable copy that kept its settings in `%APPDATA%` before keeps them after the update. Checked on Windows 11 with a portable copy beside an installed copy's settings: the first start took them (its name and start screen), a name changed there was kept in `config\` only, and without the marker the app read the installed settings again. Tests: `settings_dir_tests`. Numbers unchanged | | 2026-10-30 | minor | M1, #965, #1438: "Parent device is lost" at start. Both reports lost the device while wgpu compiled its indirect-call validation shaders, which it builds with every device: on Windows Direct3D 12 took another program's `dxcompiler.dll` from the search path and failed without its `dxil.dll`; Intel Macs' Metal compiler aborts on them. Direct3D 12 now compiles with FXC (part of Windows; `WGPU_DX12_COMPILER` still chooses), and the validation shaders are left out (FigCraft draws nothing indirectly; `WGPU_VALIDATION_INDIRECT_CALL` turns them back on). When the graphics still fail before the window's first frame, the app starts itself again with the next attempt, from a fixed list: on Windows Direct3D 12/OpenGL, Vulkan, OpenGL, then the first again on the power-saving GPU with WebGL2-level device limits; on Linux and FreeBSD the defaults, OpenGL, then lower requirements; on macOS the defaults, then lower requirements. The last failure's message names what was tried; the log names the backend and GPU that drew. `WGPU_BACKEND` is still the one attempt. A restarted app waits up to 5 s for its `--control` port. Checked on Windows 11 with a local build that made the first one, two, three and then all four attempts fail: each fallback opened the window (Vulkan, OpenGL, Direct3D 12 on the integrated GPU) and four failures ended with the message. Not run on the reporters' machines or on macOS. Tests: `graphics_tests`, `startup_tests`. Numbers unchanged | | 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 8fb1c60b9..00a2f1dfb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); `comment.list {mentions}` lists only the threads that @mention a name (#1415); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -76,7 +76,9 @@ blockers. | Date | Change | Summary | |---|---|---| | 2026-10-30 | minor | M9, #1415 (the rest): `comment.people` lists names that were only @mentioned; `comment.next` (with the `mentions` filter) and `comment.prompt` carry the same mentions as `comment.list`. Tests: `comment_mentions_tests` | +| 2026-10-30 | minor | M1, #1390: Preferences ▸ Scroll wheel zoom: a mouse wheel zooms about the pointer and Cmd/Ctrl+wheel scrolls; Shift+wheel, trackpads and pinches unchanged (wheels told from trackpads by the device macOS reports, elsewhere by line versus pixel steps). Tests: `wheel_zoom_tests` | | 2026-10-30 | minor | M9, part of #1415: `comment.list {mentions}` lists only the threads where a message @mentions a name (any case, by the same parser as the highlighting), combined with the other filters. Tests: `comment_mentions_tests` | +| 2026-10-30 | minor | M8, #1123: in the browser, where a panic can't be caught, the page's panic hook keeps its files with unsaved changes in local storage (as many as fit) and the next page opens them again as unsaved files; `file.recover` takes the copy as base64 `data`. Tests: `recovery_tests` (figcraft-web) | | 2026-10-30 | minor | M8, #1104: boolean operations refuse input past 250 000 segments, 2 million possible crossings or 4 million segments swept over a fold (`TooComplex`, before the sweep runs); a boolean group past them draws without its result, with a warning, and Flatten refuses it. Tests: `budget_tests`, `boolean_budget_tests`, `boolean_cap_tests` | | 2026-10-30 | minor | M0.9, #1396: `figcraft-cli` parses its arguments with clap, following RFC-0012 (craft-contrib), so help comes from the same definitions the parser uses and bad arguments fail before any work starts. Changed syntax: an option given twice is a usage error (it used to take the last value; the automation roots still repeat), a value starting with `-` needs `=` (`--filter=--json`), `--o` for `-o` and the `version` subcommand are gone (`--version` stays), `--format` is checked before the file is opened, help and usage errors are clap's text (`Usage: figcraft-cli …`, `error: …` with a hint instead of the whole usage). Every subcommand, flag, value form and exit code is otherwise unchanged. Tests: `args::tests` (each documented form, every usage error, every subcommand's help) and the CLI integration tests. Numbers unchanged | | 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 474c1bfe5..2b00049a4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -99,7 +99,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| -| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | +| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | | Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only | | Frame labels (rotated, right-to-left), size badge | done | | | Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on | @@ -175,6 +175,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Preferences ▸ Scroll wheel zoom (#1390): a mouse wheel zooms about the pointer and Cmd/Ctrl+wheel scrolls; Shift+wheel, trackpads and pinches unchanged. Numbers unchanged. | | 2026-10-30 | minor | Boolean groups past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold; #1104) draw without their result, with a warning, and Flatten refuses them. Numbers unchanged. | | 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged | | 2026-10-29 | minor | Colour picker review follow-up: opening the first picker preserves a text undo interaction; placement follows the rendered sidebar; opening controls track their widget ids while moving; linked selection-colour names toggle closed; gradient variable-picker presses keep the parent open. Regression tests cover each case; estimates unchanged. |