diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29..ace7702e 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-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. @@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/apps/figcraft-server/src/hardening_tests.rs b/apps/figcraft-server/src/hardening_tests.rs index 6a74e617..086539ca 100644 --- a/apps/figcraft-server/src/hardening_tests.rs +++ b/apps/figcraft-server/src/hardening_tests.rs @@ -800,3 +800,41 @@ fn the_server_forgets_addresses_whose_connections_are_gone() { assert!(server.blocks_remembered() <= 2, "{} blocks remembered", server.blocks_remembered()); server.stop(); } + +#[test] +fn a_library_publish_that_fails_keeps_serving_the_old_version() { + let dir = TempDir::new("libfail"); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap(); + // Version 1, then a changed library as version 2. + let mut s = Session::new(); + let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap(); + let c = s.execute("component.create", &json!({"ids": [f]})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.publish", &json!({})).unwrap(); + let v1 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap(); + s.execute("node.set", &json!({"id": c, "props": {"fill": "#00ff00"}})).unwrap(); + s.execute("library.publish", &json!({})).unwrap(); + let v2 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap(); + assert_eq!(store.publish_library(&file.id, &v1).unwrap(), 1); + let files = dir.path().join(crate::store::FILES).join(&file.id); + // The description can't be written: a directory sits where its temporary file goes. + let tmp = crate::disk::temp_of(&files.join(crate::disk::META)); + std::fs::create_dir(&tmp).unwrap(); + assert!(store.publish_library(&file.id, &v2).is_err()); + let fetch = |s: &mut Store| s.library(ip("198.51.100.1"), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None }); + let served = |reply: LibraryReply| match reply { + LibraryReply::Library { version, archive } => (version, archive), + other => panic!("{other:?}"), + }; + assert_eq!(served(fetch(&mut store)), (1, v1.clone()), "version 1, with its own bytes"); + // A crash right after the archive was written leaves it on disk, the description unchanged. + std::fs::write(files.join(crate::store::library_name(2)), &v2).unwrap(); + drop(store); + std::fs::remove_dir(&tmp).unwrap(); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + assert_eq!(served(fetch(&mut store)), (1, v1), "still version 1 after a restart"); + // Publishing again works, and serves the new version. + assert_eq!(store.publish_library(&file.id, &v2).unwrap(), 2); + assert_eq!(served(fetch(&mut store)), (2, v2)); + assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted"); +} diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index e5e56b8f..8dfbb708 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -10,7 +10,8 @@ //! and a join code's hash and wrapped token (see `codes`) //! /files//snapshot.bin the session state at the last compaction //! /files//log.bin what changed since (see `disk`) -//! /files//library.bin its team library, when one was published (a `.figcraft` archive) +//! /files//library-.bin its team library, when one was published (a `.figcraft` +//! archive; `library.bin` from servers before versioned names) //! /files//usage.json how other files use that library, as their apps report it //! /plugins/ the plugin directory (see `directory`) //! /blocked.json addresses the operator blocked @@ -179,7 +180,7 @@ impl Meta { } } -/// What a file's team library is (`library.bin` holds it). +/// What a file's team library is ([`library_file`] holds it). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] struct LibraryMeta { id: String, @@ -265,6 +266,9 @@ pub struct Store { next_sweep: u64, /// Compactions so far (tests count them). compactions: u64, + /// Open files closed by a compaction cut short (their snapshot replaced, their log not): + /// reported at the next flush, so nothing appended after it is confirmed. + cut_short: Vec, /// The free space last seen on the data directory's disk, and when. free: Option<(Instant, Option)>, _lock: File, @@ -360,6 +364,7 @@ impl Store { gone_codes: VecDeque::new(), next_sweep: 0, compactions: 0, + cut_short: Vec::new(), free: None, _lock: lock, }; @@ -919,11 +924,12 @@ impl Store { /// A file's team library for `ip`, unless its version is `have`; within what one address and /// everyone may download (the size is known before anything is read). fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option) -> Result { - let (path, max) = (self.dir(file).join(LIBRARY), self.cfg.max_file_bytes); + let (dir, max) = (self.dir(file), self.cfg.max_file_bytes); let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) }; if have == Some(lib.version) { return Ok(LibraryReply::Unchanged { version: lib.version }); } + let path = library_file(&dir, lib.version); let size = fs::metadata(&path).map_or(0, |m| m.len()); let now = Instant::now(); if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) { @@ -965,14 +971,24 @@ impl Store { return Err(format!("version {} is on the server already: publish again to follow it", old.version)); } } - // The archive first: a description never names a version whose library isn't there (a - // failed description write leaves the older number, which the next publish follows). - disk::write_atomic(&dir.join(LIBRARY), archive).map_err(|e| format!("couldn't keep the library: {e}"))?; + // Each version has a file of its own, written before the description names it: a failed + // description write or a crash in between leaves the previous version served whole, never + // the new bytes under the old number. + let path = dir.join(library_name(m.version)); + disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?; let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version }); if let Err(e) = write_json(&dir.join(META), &entry.meta) { + // Never served (the description doesn't name it); the next publish of it replaces it. + if let Err(r) = fs::remove_file(&path) { + log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}"); + } entry.meta.library = before; return Err(format!("couldn't keep the library: {e}")); } + // The previous version isn't served any more. + if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) { + log::warn!("file {file}: the previous library version couldn't be deleted: {e}"); + } log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len()); Ok(m.version) } @@ -1124,7 +1140,7 @@ impl Store { Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } } // A new snapshot and log of the next generation, so appends start clean. - _ => self.rewrite(file, generation + 1, &server)?, + _ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?, }; self.entry(file)?.log = Some(log); Ok(server) @@ -1137,21 +1153,24 @@ impl Store { } /// Write `server`'s state as snapshot `generation` and start an empty log of it. - fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result { + fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result { let dir = self.dir(file); - let state = server.state().map_err(|e| e.to_string())?; - let bytes = disk::encode_snapshot(generation, &state)?; + let kept = |why: String| RewriteError { why, snapshot_replaced: false }; + let state = server.state().map_err(|e| kept(e.to_string()))?; + let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?; // Never replace a readable snapshot with one that can't be opened again: the old snapshot // and log still hold everything. if bytes.len() as u64 > self.snapshot_cap() { - return Err(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20)); + return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20))); } if bytes.len() as u64 > self.cfg.max_file_bytes { log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20); } - disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| format!("couldn't write the snapshot: {e}"))?; - disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| format!("couldn't start the log: {e}"))?; - let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| format!("couldn't open the log: {e}"))?; + disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?; + // From here the old log no longer goes with the snapshot on disk. + let replaced = |why: String| RewriteError { why, snapshot_replaced: true }; + disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?; + let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?; if let Ok(e) = self.entry(file) { e.bytes = (bytes.len() + LOG_HEADER) as u64; e.meta.seq = server.seq(); @@ -1204,8 +1223,19 @@ impl Store { } log::debug!("compacted file {file}"); } + // The new snapshot holds everything, but a restart would drop whatever is appended to + // the old log now (its generation no longer matches): the file closes, the hub hears + // it at the next flush, and it opens again from the new snapshot. + Err(RewriteError { why, snapshot_replaced: true }) => { + self.note(format!("file {file}: compaction was cut short ({why}); it was closed")); + log::error!("file {file}: compaction was cut short: {why}"); + if let Some(e) = self.files.get_mut(file) { + e.log = None; + } + self.cut_short.push(file.to_string()); + } // The old snapshot and log still hold everything: carry on appending. - Err(e) => log::error!("file {file}: compaction failed: {e}"), + Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"), } } @@ -1213,7 +1243,7 @@ impl Store { /// Those are closed here (their pending records dropped: nobody was told of them) and open /// again from what is on disk. fn flush_all(&mut self) -> Vec { - let mut failed = Vec::new(); + let mut failed = std::mem::take(&mut self.cut_short); for (id, e) in &mut self.files { if let Some(log) = e.log.as_mut() { match flush_log(id, log) { @@ -1229,6 +1259,13 @@ impl Store { } } +/// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced +/// before it failed (then the old log no longer goes with it). +struct RewriteError { + why: String, + snapshot_replaced: bool, +} + /// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log /// can't be trusted to hold them). fn flush_log(id: &str, log: &mut Log) -> Result { @@ -1249,6 +1286,18 @@ fn flush_log(id: &str, log: &mut Log) -> Result { } } +/// The name of the file holding version `version` of a file's team library. +pub fn library_name(version: u64) -> String { + format!("library-{version}.bin") +} + +/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a +/// version published before each version had one. +fn library_file(dir: &Path, version: u64) -> PathBuf { + let path = dir.join(library_name(version)); + if path.exists() { path } else { dir.join(LIBRARY) } +} + fn write_json(path: &Path, value: &T) -> Result<(), String> { let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?; disk::write_atomic(path, &text).map_err(|e| e.to_string()) @@ -1316,6 +1365,8 @@ impl Backend for Store { if dirty { self.compact(file, server); } + // It closes anyway, everything in its snapshot: a compaction cut short needs no report. + self.cut_short.retain(|f| f != file); if let Some(e) = self.files.get_mut(file) { e.log = None; } diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index fda51d4f..f380568c 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -269,3 +269,47 @@ fn random_bytes_never_panic_the_readers() { let _ = disk::read_log(&bytes); } } + +/// The first page's name in `server`'s document. +fn page_name(server: &Server) -> String { + let d = server.replica().document(); + d.node(d.first_page().unwrap()).unwrap().name.clone() +} + +#[test] +fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() { + let dir = TempDir::new("cutshort"); + let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) }; + let folder = dir.path().join(FILES); + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Busy"); + let mut server = store.open(&id).unwrap(); + let mut a = join(&mut server, Access::Edit); + store.changed(&id, &mut server); + // The next compaction writes its snapshot, then can't start its log. + let blocker = disk::temp_of(&folder.join(&id).join(LOG)); + fs::create_dir(&blocker).unwrap(); + // What the hub would confirm: every change whose flush didn't report the file. + let mut confirmed = String::new(); + let mut failed = false; + let names = ["P0", "P1", "P2", "After 1", "After 2"]; + for name in names { + rename(&mut server, &mut a, name); + store.changed(&id, &mut server); + if store.flush().contains(&id) { + failed = true; + break; + } + confirmed = name.to_string(); + } + assert!(failed, "the hub hears the file can't be kept; confirmed {confirmed:?}"); + // A crash, then the disk recovers. + drop(store); + fs::remove_dir(&blocker).unwrap(); + let mut store = Store::open(dir.path(), c).unwrap(); + let restored = store.open(&id).unwrap(); + // Every confirmed change is there (the snapshot may also hold the one that was cut short). + let at = |n: &str| names.iter().position(|x| *x == n); + let name = page_name(&restored); + assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}"); +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 63689038..5bb20905 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -40,7 +40,7 @@ pub fn start() { // 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 fig = FigApp::new(session, Box::new(WebServices { reduce_motion })); + let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); Ok(Box::new(WebShell { fig })) }), ) @@ -69,14 +69,22 @@ impl eframe::App for WebShell { } } -/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard -/// goes through egui's own web clipboard handling. +/// Browser services. File dialogs return nothing for now (the trait's defaults). Copies go to the +/// system clipboard through egui's output; pastes arrive as paste events with their text (a page +/// can't read the clipboard when it likes). struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option, + ctx: egui::Context, } impl Services for WebServices { + fn clipboard_set_text(&mut self, s: &str) { + figcraft_ui_egui::egui_copy_text(&self.ctx, s); + } + fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { + figcraft_ui_egui::egui_copy_png(&self.ctx, w, h, rgba); + } fn open_url(&mut self, url: &str) { let Some(window) = web_sys::window() else { return }; if let Err(e) = window.open_with_url_and_target(url, "_blank") { diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index f4aeb63d..4a14a73a 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -59,12 +59,16 @@ impl Desktop { /// The system's preferred interface languages, most preferred first, comma-separated /// ("nl-NL,de-DE,en-US"): Windows' language list, macOS's AppleLanguages, `LANGUAGE` and the -/// locale variables elsewhere. The UI shows the first one it has a catalog for. +/// locale variables elsewhere. The UI shows the first language in the list that has a catalog. +/// Windows keeps only its first preferred language (and English when it has no catalog): that +/// list is also the input-language list, so a language added for a keyboard (Deutsch on a Polish +/// system) must not decide the interface language. fn system_language() -> Option { #[cfg(target_os = "windows")] { windows::System::UserProfile::GlobalizationPreferences::Languages() - .map(|l| l.into_iter().map(|s| s.to_string()).collect::>().join(",")) + .and_then(|l| l.GetAt(0)) + .map(|s| s.to_string()) .inspect_err(|e| log::info!("language: can't read the system languages: {e}")) .ok() } diff --git a/contributors/people.toml b/contributors/people.toml index e61cfd31..06699f06 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -16,3 +16,10 @@ # # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. + +[people.tylerjenningsw] +real_name = "Tyler Jennings" +display_name = "tylerjenningsw" + +[people.VanTan666] +display_name = "VanTan" diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 2c088cfa..43cfcf3c 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -145,21 +145,28 @@ pub fn rotate_world(doc: &mut Document, id: NodeId, pivot: Point, deg: f64) -> R Ok(()) } +/// Whether aligning `ids` would move anything: a layer alone on the page has nothing to align to. +pub fn can_align(doc: &Document, ids: &[NodeId]) -> bool { + align_target(doc, &roots(doc, ids)).is_some() +} + +/// What `roots` align to: one layer to its parent frame, several to their combined bounds. +fn align_target(doc: &Document, roots: &[NodeId]) -> Option { + match roots { + [] => None, + [id] => { + let parent = doc.node(*id)?.parent.filter(|pp| !doc.node(*pp).is_some_and(Node::is_page))?; + Some(doc.world_bounds(parent).unwrap_or_default()) + } + _ => Some(doc.bounds_of(roots).unwrap_or_default()), + } +} + fn align(s: &mut Session, p: &Value, a: Al) -> Result { let ids = req_targets("align", s, p)?; let doc = s.doc_mut()?; let ids = roots(doc, &ids); - // One layer aligns within its parent frame; several align to their combined bounds. - let target: Rect = if ids.len() == 1 { - let Some(id) = ids.first() else { return ok() }; - let parent = doc.get(*id)?.parent; - match parent.filter(|pp| !doc.node(*pp).is_some_and(Node::is_page)) { - Some(pp) => doc.world_bounds(pp).unwrap_or_default(), - None => return ok(), - } - } else { - doc.bounds_of(&ids).unwrap_or_default() - }; + let Some(target) = align_target(doc, &ids) else { return ok() }; for i in ids { let Some(b) = doc.world_bounds(i) else { continue }; let (dx, dy) = match a { @@ -325,8 +332,12 @@ fn zorder(s: &mut Session, p: &Value, z: Z) -> Result { } } if matches!(z, Z::Back) { - for (k, i) in ids.iter().enumerate() { - doc.reorder(*i, k)?; + let mut next: std::collections::HashMap = std::collections::HashMap::new(); + for i in &ids { + let Some(pp) = doc.get(*i)?.parent else { continue }; + let k = next.entry(pp).or_insert(0); + doc.reorder(*i, *k)?; + *k += 1; } } ok() diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 63442b3c..61b0f8b4 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -242,7 +242,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { let doc = ds.doc_mut(); let nid = doc.alloc_id(); let mut node = if base == "Section" { Node::new_section(nid, name, w, h) } else { Node::new(nid, kind, name, w, h) }; - if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, doc.page_of(parent).unwrap_or(page)) { + if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, parent) { node.fills = fills; } node.transform = space.inverse() * figcraft_geom::Affine::translate((x + offset.0, y + offset.1)); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 57ba9fb0..df6afc6d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { spec!( "edit.duplicate", "Duplicate", - "{ids?, dx?, dy? (offset; default 0 for frames-in-place... Figma offsets top-level frames to the right)}", + "{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}", true, duplicate ), @@ -412,13 +412,48 @@ fn delete(s: &mut Session, p: &Value) -> Result { Ok(json!({"deleted": ids})) } +/// Layers duplicated last (Duplicate or Alt-drag) and their copies, in the same order. While the +/// copies are the selection, Duplicate places the next copies as far from them as they are from +/// their sources, measured then: so moving the copies first sets the step. +#[derive(Clone, Debug)] +pub struct Duplicated { + pub sources: Vec, + pub copies: Vec, +} + +impl Duplicated { + pub fn is_selection(&self, sel: &[NodeId]) -> bool { + let set = |v: &[NodeId]| v.iter().copied().collect::>(); + !self.copies.is_empty() && set(&self.copies) == set(sel) + } + + /// The page offset from the sources to the copies, if both are still there and the copies move + /// freely (an auto layout places its own). + fn offset(&self, doc: &Document) -> Option<(f64, f64)> { + let free = self + .copies + .iter() + .all(|c| doc.node(*c).is_some_and(|n| n.child_layout.absolute || n.parent.and_then(|p| doc.node(p)).is_none_or(|p| p.layout.is_none()))); + if !free || self.sources.iter().chain(&self.copies).any(|i| !doc.contains(*i)) { + return None; + } + let (a, b) = (doc.bounds_of(&self.sources)?, doc.bounds_of(&self.copies)?); + let d = (b.x0 - a.x0, b.y0 - a.y0); + (d.0.is_finite() && d.1.is_finite() && (d.0 != 0.0 || d.1 != 0.0)).then_some(d) + } +} + fn duplicate(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.duplicate", s, p)?; let rename = s.prefs.rename_duplicates; + let explicit = num(p, "dx").is_some() || num(p, "dy").is_some(); + let ds = s.ds()?; + let roots_now = z_sorted(&ds.doc, &roots(&ds.doc, &ids)); + let repeat = ds.last_duplicate.as_ref().filter(|d| !explicit && d.is_selection(&roots_now)).and_then(|d| d.offset(&ds.doc)); let doc = s.doc_mut()?; - let ids = z_sorted(doc, &roots(doc, &ids)); + let ids = roots_now; let mut out = Vec::new(); - for i in ids { + for i in ids.iter().copied() { let n = doc.get(i)?; let parent = n.parent.ok_or_else(|| bad("edit.duplicate", "pages are duplicated with page.duplicate"))?; let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like(); @@ -432,14 +467,21 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { _ if top_frame => (w + 100.0, 0.0), _ => (0.0, 0.0), }; - let nn = doc.get_mut(nid)?; - nn.transform = Affine::translate((dx, dy)) * nn.transform; + match repeat { + Some((rx, ry)) => super::arrange::move_world(doc, nid, rx, ry)?, + None => { + let nn = doc.get_mut(nid)?; + nn.transform = Affine::translate((dx, dy)) * nn.transform; + } + } if rename { + let nn = doc.get_mut(nid)?; nn.name = bump_name(&nn.name); } out.push(nid); } s.set_selection(out.clone()); + s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() }); Ok(json!({"ids": out})) } diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index b2d46988..d7f630b9 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -26,7 +26,12 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> { pub fn specs() -> Vec { vec![ spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| { - let background = s.new_page_background(); + // An added page takes the current page's colour; the theme only decides new files. + let current = s.page().ok().and_then(|p| match s.doc().ok()?.node(p)?.kind { + NodeKind::Page { background, .. } => Some(background), + _ => None, + }); + let background = current.unwrap_or_else(|| s.new_page_background()); let doc = s.doc_mut()?; let name = string(p, "name").unwrap_or_else(|| format!("Page {}", doc.pages.len() + 1)); let id = doc.add_page(&name); diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 13693d57..bed70d3d 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -362,9 +362,9 @@ fn flatten(s: &mut Session, p: &Value) -> Result { fn outline_stroke(s: &mut Session, p: &Value) -> Result { let ids = req_targets("vector.outlineStroke", s, p)?; - let doc = s.doc_mut()?; let mut out = Vec::new(); for i in ids { + let doc = s.doc_mut()?; let n = doc.get(i)?.clone(); if !n.has_visible_stroke() { continue; @@ -409,7 +409,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result { if let Some(g) = id_of(&r["id"]) { out.push(g); } - return Ok(json!({"ids": out})); + continue; } // Standing in for the layer, the outline keeps how it is drawn and placed. (v.opacity, v.blend, v.effects, v.styles.effect) = (n.opacity, n.blend, n.effects.clone(), n.styles.effect); diff --git a/crates/engine/src/dark_page_text_tests.rs b/crates/engine/src/dark_page_text_tests.rs index c4348430..b46bd0c7 100644 --- a/crates/engine/src/dark_page_text_tests.rs +++ b/crates/engine/src/dark_page_text_tests.rs @@ -1,6 +1,6 @@ -//! Text made by commands in the app (the interface reported its theme) is readable on its page -//! like text drawn with the Text tool: white on a dark page unless a fill is named. A session -//! without an interface (CLI, MCP, exports) keeps the model's black everywhere. +//! Text made by commands in the app (the interface reported its theme) is readable where it is +//! drawn, like text drawn with the Text tool: white on a dark page or a dark frame unless a fill is +//! named. A session without an interface (CLI, MCP, exports) keeps the model's black everywhere. use figcraft_color::{Color, Paint}; use figcraft_doc::NodeId; @@ -62,12 +62,51 @@ fn text_made_by_commands_is_readable_on_its_page() { } #[test] -fn text_in_a_frame_follows_the_page_it_is_on() { - // As with the Text tool: the page decides, also inside a frame; made in one undo step. +fn text_in_a_frame_is_readable_on_the_frame() { + // #362: what the text is drawn on decides: the nearest layer around it with a solid fill (a + // new frame's white too), else its page; made in one undo step. + let (white, black) = (vec![Paint::solid(Color::WHITE)], vec![Paint::solid(Color::BLACK)]); let mut s = session(Some(true)); - let frame: NodeId = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100}))["id"]).unwrap(); - let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": frame})); - assert_eq!(fills(&s, &t), vec![Paint::solid(Color::WHITE)]); + let frame = |s: &mut Session, x: i64, parent: Option, fills: Option| -> NodeId { + let mut p = json!({"x": x, "y": 0, "width": 200, "height": 100}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + let f = id_of(&ex(s, "frame.create", p)["id"]).unwrap(); + if let Some(fills) = fills { + ex(s, "node.set", json!({"id": f, "props": {"fills": fills}})); + } + f + }; + let light = frame(&mut s, 0, None, None); + let dark = frame(&mut s, 300, None, Some(json!([{"type": "solid", "color": "#202020"}]))); + let bare = frame(&mut s, 600, None, Some(json!([]))); + let inner = frame(&mut s, 0, Some(light), Some(json!([]))); + let faint = frame(&mut s, 900, None, Some(json!([{"type": "solid", "color": "#FFFFFF", "opacity": 0.2}]))); + let stacked = frame(&mut s, 1200, None, Some(json!([{"type": "solid", "color": "#202020"}, {"type": "solid", "color": "#FFFFFF"}]))); + let cases = [ + (light, &black, "a white frame on a dark page: black text"), + (dark, &white, "a dark frame: white"), + (bare, &white, "a frame without a fill: the page decides"), + (inner, &black, "a frame without a fill in a white one: the white one"), + (faint, &white, "a faint fill lets the page show through: the page decides"), + (stacked, &black, "the fill on top counts"), + ]; + for (parent, want, why) in cases { + let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": parent})); + assert_eq!(&fills(&s, &t), want, "{why}"); + } + // The Text tool reads it the same way. + use crate::tools::{PointerEvent, PointerKind, ToolId}; + let at = s.doc().unwrap().world_bounds(light).unwrap().center(); + s.set_tool(ToolId::Text); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::at(kind, at.x, at.y, 1.0)).unwrap(); + } + let typed = s.ds().unwrap().text_edit.as_ref().unwrap().id; + assert_eq!(s.doc().unwrap().node(typed).unwrap().fills, black, "the Text tool in a white frame"); + crate::textedit::end(&mut s).unwrap(); + let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": light})); ex(&mut s, "edit.undo", json!({})); assert!(s.doc().unwrap().node(id_of(&t["id"]).unwrap()).is_none()); } diff --git a/crates/engine/src/duplicate_spacing_tests.rs b/crates/engine/src/duplicate_spacing_tests.rs new file mode 100644 index 00000000..b65fbb64 --- /dev/null +++ b/crates/engine/src/duplicate_spacing_tests.rs @@ -0,0 +1,159 @@ +//! Duplicating at a spacing: a copy Alt-dragged off a layer snaps to that layer too (its edges, +//! centres and equal spacing), and Duplicate repeats the last duplicate's offset while its copies +//! are selected, so Alt-drag (or Duplicate and move) then Cmd+D steps out evenly spaced copies. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::NodeId; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn rect(s: &mut Session, x: f64, y: f64) -> NodeId { + id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": 100, "height": 100}))["id"]).unwrap() +} + +/// Alt-drag from `from` to `to`: returns the copy (the selection afterwards). +fn alt_drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId { + let ev = |kind, (x, y): (f64, f64)| PointerEvent { alt: true, ..PointerEvent::at(kind, x, y, 1.0) }; + s.pointer(&ev(PointerKind::Down, from)).unwrap(); + for k in 1..=4 { + let t = f64::from(k) / 4.0; + s.pointer(&ev(PointerKind::Move, (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t))).unwrap(); + } + s.pointer(&ev(PointerKind::Up, to)).unwrap(); + let sel = s.selection(); + assert_eq!(sel.len(), 1, "one copy selected"); + sel[0] +} + +/// Duplicate the selection: the copy. +fn cmd_d(s: &mut Session) -> NodeId { + let out = ex(s, "edit.duplicate", json!({})); + id_of(&out["ids"][0]).unwrap() +} + +fn at(s: &Session, id: NodeId) -> (f64, f64) { + let b = s.doc().unwrap().world_bounds(id).unwrap(); + (b.x0, b.y0) +} + +#[test] +fn an_alt_dragged_copy_snaps_to_the_layer_it_came_from() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + // 2 px below the original's top edge: within the snap distance. + let copy = alt_drag(&mut s, (50.0, 50.0), (250.0, 52.0)); + assert_ne!(copy, a); + assert_eq!(at(&s, copy), (200.0, 0.0), "the copy's top lines up with the original's"); + assert_eq!(at(&s, a), (0.0, 0.0), "the original stays"); +} + +#[test] +fn an_alt_dragged_copy_spaces_equally_with_the_layer_it_came_from() { + let mut s = Session::new(); + rect(&mut s, 0.0, 0.0); + rect(&mut s, 200.0, 0.0); + // B's copy dropped 2 px short of the 100 gap that A and B have. + let copy = alt_drag(&mut s, (250.0, 50.0), (448.0, 50.0)); + assert_eq!(at(&s, copy), (400.0, 0.0)); +} + +#[test] +fn duplicate_repeats_the_step_set_by_moving_the_copy() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place"); + ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150, "y": 20}})); + let c2 = cmd_d(&mut s); + assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again"); + let c3 = cmd_d(&mut s); + assert_eq!(at(&s, c3), (450.0, 60.0), "and again"); +} + +#[test] +fn duplicate_after_an_alt_drag_repeats_its_step() { + let mut s = Session::new(); + rect(&mut s, 0.0, 0.0); + let copy = alt_drag(&mut s, (50.0, 50.0), (180.0, 50.0)); + assert_eq!(at(&s, copy), (130.0, 0.0)); + let next = cmd_d(&mut s); + assert_eq!(at(&s, next), (260.0, 0.0)); +} + +#[test] +fn duplicate_only_repeats_for_the_last_copies() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}})); + // Another selection forgets it: the original duplicates in place. + ex(&mut s, "select.set", json!({"ids": [a]})); + ex(&mut s, "select.set", json!({"ids": [c1]})); + let c2 = cmd_d(&mut s); + assert_eq!(at(&s, c2), (150.0, 0.0), "the step was forgotten when the selection changed"); + // An offset given explicitly wins over a remembered one. + ex(&mut s, "node.set", json!({"ids": [c2], "props": {"x": 300}})); + let out = ex(&mut s, "edit.duplicate", json!({"dx": 10})); + let c3 = id_of(&out["ids"][0]).unwrap(); + assert_eq!(at(&s, c3), (310.0, 0.0)); +} + +#[test] +fn undo_doesnt_let_a_new_layer_take_a_remembered_step() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}})); + ex(&mut s, "edit.undo", json!({})); + ex(&mut s, "edit.undo", json!({})); + assert!(!s.doc().unwrap().contains(c1)); + // Undo gives ids back: the new rectangle may well get the undone copy's. + let r = rect(&mut s, 500.0, 500.0); + ex(&mut s, "select.set", json!({"ids": [r]})); + let c = cmd_d(&mut s); + assert_eq!(at(&s, c), (500.0, 500.0), "a plain duplicate, in place"); +} + +#[test] +fn the_step_is_measured_in_page_space_inside_a_rotated_frame() { + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 600, "height": 600}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [f], "props": {"rotation": 30}})); + let a = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 40, "height": 40, "parent": f}))["id"]).unwrap(); + ex(&mut s, "select.set", json!({"ids": [a]})); + let c1 = cmd_d(&mut s); + ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}})); + let (p0, p1) = (at(&s, a), at(&s, c1)); + let c2 = cmd_d(&mut s); + let p2 = at(&s, c2); + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + assert!(close(p2.0 - p1.0, p1.0 - p0.0) && close(p2.1 - p1.1, p1.1 - p0.1), "{p0:?} {p1:?} {p2:?}"); +} + +#[test] +fn an_alt_drag_into_auto_layout_sets_no_step() { + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100}))["id"]).unwrap(); + for i in 0..2 { + ex(&mut s, "shape.rectangle", json!({"x": i * 60, "y": 0, "width": 50, "height": 30, "parent": f})); + } + ex(&mut s, "layout.add", json!({"ids": [f]})); + ex(&mut s, "select.none", json!({})); + let kids = s.doc().unwrap().node(f).unwrap().children.clone(); + let b = s.doc().unwrap().world_bounds(kids[0]).unwrap(); + let from = (b.center().x, b.center().y); + alt_drag(&mut s, from, (from.0 + 70.0, from.1)); + assert!(s.ds().unwrap().last_duplicate.is_none(), "the layout placed the copy: no step to repeat"); + let n = s.doc().unwrap().node(f).unwrap().children.len(); + cmd_d(&mut s); + assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a90..aadbbbd5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -328,6 +328,9 @@ pub struct DocState { pub guide: Option<(NodeId, usize)>, /// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved). pub(crate) rotation_origin: Option, + /// The last duplicate while its copies are the selection: Duplicate repeats its offset (see + /// [`cmd::edit::Duplicated`]; not saved). + pub(crate) last_duplicate: Option, } /// A version shown on the canvas in place of the document: read-only until it's restored or the @@ -383,6 +386,7 @@ impl DocState { focus: None, guide: None, rotation_origin: None, + last_duplicate: None, }; st.zoom_to_fit(); st @@ -538,6 +542,10 @@ impl DocState { if self.rotation_origin.as_ref().is_some_and(|o| o.ids != self.selection) { self.rotation_origin = None; } + // So does the last duplicate (undo reuses ids: a new layer could take a copy's). + if self.last_duplicate.as_ref().is_some_and(|d| !d.is_selection(&self.selection)) { + self.last_duplicate = None; + } } } @@ -552,6 +560,7 @@ pub struct Prefs { pub highlight_on_hover: bool, pub number_keys_opacity: bool, pub show_dimensions: 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 /// limits; other windows have their own while they are open). @@ -611,7 +620,7 @@ impl Default for Prefs { highlight_on_hover: true, number_keys_opacity: true, show_dimensions: true, - theme: "light".into(), + theme: "system".into(), panel_widths: (240.0, 240.0), language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), @@ -663,8 +672,8 @@ pub struct Session { pub prefs: Prefs, pub tool: tools::ToolState, pub ui_requests: Vec, - /// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files and pages - /// get the dark page colour while it is dark. Headless sessions (CLI, MCP, exports) have no + /// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files get the + /// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no /// theme, so what scripts make is the same everywhere. pub ui_dark: Option, /// Command log (id + params) for actions/automation. @@ -721,7 +730,7 @@ impl Session { } } - /// The background new pages get: the dark page colour while the interface is dark. + /// The background a new file's page gets: the dark page colour while the interface is dark. pub fn new_page_background(&self) -> figcraft_color::Color { if self.ui_dark == Some(true) { figcraft_color::Color::DARK_PAGE } else { figcraft_color::Color::DEFAULT_PAGE } } @@ -1441,6 +1450,8 @@ mod double_click_tests; #[cfg(test)] mod draw_tools_tests; #[cfg(test)] +mod duplicate_spacing_tests; +#[cfg(test)] mod effects_tests; #[cfg(test)] mod export_behaviour_tests; @@ -1517,6 +1528,8 @@ mod panels_behaviour_tests; #[cfg(test)] mod pdf_print_tests; #[cfg(test)] +mod pen_overlay_tests; +#[cfg(test)] mod pixel_preview_tests; #[cfg(test)] mod playground_tests; diff --git a/crates/engine/src/measure_tests.rs b/crates/engine/src/measure_tests.rs index 2329e7be..539386bd 100644 --- a/crates/engine/src/measure_tests.rs +++ b/crates/engine/src/measure_tests.rs @@ -196,16 +196,17 @@ fn alt_drag_shows_live_distances_to_the_neighbours() { e }; s.pointer(&ev(PointerKind::Down, 30.0)).unwrap(); - // Alt duplicates: the copy moves to 110..160, between the original and the other layer. + // Alt duplicates: the copy, dragged to 110..160, snaps to equal spacing between the original + // and the other layer (105..155). s.pointer(&ev(PointerKind::Move, 130.0)).unwrap(); let g = s.tool.overlay.guides.clone(); assert_eq!( labelled(&g), vec![ - ("30".into(), [135, 0, 135, 30]), - ("30".into(), [135, 70, 135, 100]), - ("40".into(), [160, 50, 200, 50]), - ("50".into(), [60, 50, 110, 50]) + ("30".into(), [130, 0, 130, 30]), + ("30".into(), [130, 70, 130, 100]), + ("45".into(), [60, 50, 105, 50]), + ("45".into(), [155, 50, 200, 50]) ] ); s.pointer(&ev(PointerKind::Up, 130.0)).unwrap(); diff --git a/crates/engine/src/pen_overlay_tests.rs b/crates/engine/src/pen_overlay_tests.rs new file mode 100644 index 00000000..7e0064d2 --- /dev/null +++ b/crates/engine/src/pen_overlay_tests.rs @@ -0,0 +1,65 @@ +//! What the Pen tool shows while a path is drawn (`tool.overlay`): the handles shaping the segments +//! at the pen, and the rubber band to the pointer, which follows the last anchor's out handle. + +use crate::Session; +use crate::tools::{PointerEvent, PointerKind, ToolId}; + +fn at(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap(); +} + +/// A press at (x, y) dragged to (to_x, to_y) and released: a smooth point with out handle +/// (to - p). +fn drag_point(s: &mut Session, x: f64, y: f64, to_x: f64, to_y: f64) { + at(s, PointerKind::Down, x, y); + at(s, PointerKind::Move, to_x, to_y); + at(s, PointerKind::Up, to_x, to_y); +} + +#[test] +fn the_dragged_point_shows_its_handles_and_the_one_before_its_out_handle() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + drag_point(&mut s, 100.0, 100.0, 140.0, 100.0); + assert_eq!(s.tool.overlay.pen_handles, vec![[100.0, 100.0, 60.0, 100.0], [100.0, 100.0, 140.0, 100.0]], "in and out of the first point"); + // The second point, mid-drag. + at(&mut s, PointerKind::Down, 200.0, 200.0); + assert_eq!(s.tool.overlay.rubber, None, "the press shows the segment itself, not a straight band"); + at(&mut s, PointerKind::Move, 200.0, 240.0); + assert_eq!( + s.tool.overlay.pen_handles, + vec![[100.0, 100.0, 140.0, 100.0], [200.0, 200.0, 200.0, 160.0], [200.0, 200.0, 200.0, 240.0]], + "the one before's out handle, then the dragged point's in and out" + ); + at(&mut s, PointerKind::Up, 200.0, 240.0); + // Moving on: the band leaves along the last out handle. + at(&mut s, PointerKind::Hover, 300.0, 200.0); + assert_eq!(s.tool.overlay.rubber, Some([200.0, 200.0, 300.0, 200.0])); + assert_eq!(s.tool.overlay.rubber_handle, Some([200.0, 240.0])); +} + +#[test] +fn corner_points_have_no_handles_and_a_straight_band() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + for (x, y) in [(100.0, 100.0), (200.0, 100.0)] { + at(&mut s, PointerKind::Down, x, y); + at(&mut s, PointerKind::Up, x, y); + } + at(&mut s, PointerKind::Hover, 250.0, 200.0); + assert!(s.tool.overlay.pen_handles.is_empty()); + assert_eq!(s.tool.overlay.rubber, Some([200.0, 100.0, 250.0, 200.0])); + assert_eq!(s.tool.overlay.rubber_handle, None, "straight"); +} + +#[test] +fn finishing_the_path_clears_its_handles_and_band() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + drag_point(&mut s, 100.0, 100.0, 140.0, 100.0); + drag_point(&mut s, 200.0, 200.0, 200.0, 240.0); + at(&mut s, PointerKind::Hover, 300.0, 200.0); + s.pen_finish().unwrap(); + let ov = &s.tool.overlay; + assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none()); +} diff --git a/crates/engine/src/shapes_behaviour_tests.rs b/crates/engine/src/shapes_behaviour_tests.rs index e97cae04..154c5ae1 100644 --- a/crates/engine/src/shapes_behaviour_tests.rs +++ b/crates/engine/src/shapes_behaviour_tests.rs @@ -431,3 +431,31 @@ fn flatten_and_outline_stroke() { let (path, _) = figcraft_render::node_outline(s.doc().unwrap(), &n).unwrap(); assert!((path.bounding_box().height() - 10.0).abs() < 1e-6, "the outline is as thick as the stroke was"); } + +#[test] +fn outline_stroke_outlines_every_selected_layer() { + // Fixed (#378): with several filled, stroked shapes selected, only the first was outlined. + for by_selection in [false, true] { + let mut s = Session::new(); + let a = create(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})); + let b = create(&mut s, "shape.rectangle", json!({"x": 100, "y": 0, "width": 50, "height": 50})); + let line = create(&mut s, "shape.line", json!({"x": 0, "y": 100, "width": 100, "props": {"strokeWeight": 4}})); + ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"strokes": ["#000000"], "strokeWeight": 4}})); + let out = if by_selection { + s.set_selection(vec![a, b, line]); + ex(&mut s, "vector.outlineStroke", json!({})) + } else { + ex(&mut s, "vector.outlineStroke", json!({"ids": [a, b, line]})) + }; + let ids: Vec = out["ids"].as_array().unwrap().iter().filter_map(id_of).collect(); + assert_eq!(ids.len(), 3, "one result per layer: {out}"); + assert!(matches!(node(&s, ids[0]).kind, NodeKind::Group), "filled shape a becomes a group"); + assert!(matches!(node(&s, ids[1]).kind, NodeKind::Group), "filled shape b becomes a group"); + assert!(matches!(node(&s, ids[2]).kind, NodeKind::Vector { .. }), "the line becomes a vector"); + for id in [a, b] { + assert!(node(&s, id).strokes.is_empty(), "{id:?} still has its stroke"); + } + assert!(s.doc().unwrap().node(line).is_none(), "the line was replaced by its outline"); + assert_eq!(s.selection(), ids, "the results are selected"); + } +} diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 82474df0..b68f1fe9 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -74,15 +74,6 @@ impl Targets { // A layer's box in snap space. let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite()); let mut t = Targets { space, moving: moving.iter().filter_map(|m| bbox(*m)).reduce(|a, b| a.union(b)), ..Default::default() }; - let push = |r: Rect, t: &mut Targets| { - for x in [r.x0, r.center().x, r.x1] { - t.xs.push((x, r.y0, r.y1)); - } - for y in [r.y0, r.center().y, r.y1] { - t.ys.push((y, r.x0, r.x1)); - } - t.boxes.push(r); - }; let parent = parent_of(doc, page, moving); // The parent frame's own box (and its layout grids). if parent != page @@ -109,7 +100,7 @@ impl Targets { continue; } if let Some(b) = bbox(*c) { - push(b, &mut t); + t.add_box(b); } } } @@ -136,6 +127,17 @@ impl Targets { t } + /// A box to snap to (in snap space): its edges and centre lines, and equal spacing with it. + pub fn add_box(&mut self, r: Rect) { + for x in [r.x0, r.center().x, r.x1] { + self.xs.push((x, r.y0, r.y1)); + } + for y in [r.y0, r.center().y, r.y1] { + self.ys.push((y, r.x0, r.x1)); + } + self.boxes.push(r); + } + /// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes, /// and a marker when a moving point lands on it. pub fn add_point(&mut self, p: Point) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 40da2f1d..9ddefb9f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -253,6 +253,49 @@ fn align_distribute_zorder() { assert_eq!(s.doc().unwrap().node(page).unwrap().children[0], c); } +#[test] +fn a_layer_alone_on_the_page_has_nothing_to_align_to() { + use crate::cmd::arrange::can_align; + let mut s = sess(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()); + let inner = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 10, "height": 10, "parent": f})).unwrap()); + let loose = id(&s.execute("shape.rectangle", &json!({"x": 300, "y": 5, "width": 10, "height": 10})).unwrap()); + let doc = s.doc().unwrap(); + assert!(!can_align(doc, &[])); + assert!(!can_align(doc, &[loose]), "alone on the page"); + assert!(!can_align(doc, &[f, inner]), "a frame with its own child is the frame alone"); + assert!(can_align(doc, &[inner]), "one in a frame aligns to it"); + assert!(can_align(doc, &[f, loose]), "two align to each other"); + // Where the buttons are off, the command still does nothing (and no harm). + let before = doc.world_bounds(loose); + s.execute("align.right", &json!({"ids": [loose]})).unwrap(); + assert_eq!(s.doc().unwrap().world_bounds(loose), before); + s.execute("align.right", &json!({"ids": [inner]})).unwrap(); + assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap().x1, 200.0); +} + +#[test] +fn send_to_back_across_frames_counts_per_parent() { + let mut s = sess(); + let make = |s: &mut Session, p: Value| id(&s.execute("node.create", &p).unwrap()); + let fa = make(&mut s, json!({"type": "frame", "x": 0, "y": 0, "width": 200, "height": 200})); + let fb = make(&mut s, json!({"type": "frame", "x": 300, "y": 0, "width": 200, "height": 200})); + let rect = |parent: NodeId| json!({"type": "rectangle", "parent": parent, "x": 10, "y": 10, "width": 20, "height": 20}); + let a: Vec = (0..2).map(|_| make(&mut s, rect(fa))).collect(); + let b: Vec = (0..3).map(|_| make(&mut s, rect(fb))).collect(); + let idx = |s: &Session, n: NodeId| s.doc().unwrap().index_in_parent(n); + assert_eq!(idx(&s, a[1]), Some(1)); + assert_eq!(idx(&s, b[2]), Some(2)); + s.execute("arrange.back", &json!({"ids": [a[1], b[2]]})).unwrap(); + assert_eq!(idx(&s, a[1]), Some(0), "top of frame A at the back"); + assert_eq!(idx(&s, b[2]), Some(0), "top of frame B at the back"); + // Two from B plus one from A: each parent's selection ends at 0..n in its original order. + s.execute("arrange.back", &json!({"ids": [b[1], a[0], b[0]]})).unwrap(); + let kids = |s: &Session, f: NodeId| s.doc().unwrap().node(f).unwrap().children.clone(); + assert_eq!(kids(&s, fa), vec![a[0], a[1]]); + assert_eq!(kids(&s, fb), vec![b[0], b[1], b[2]]); +} + #[test] fn copy_paste_and_duplicate() { let mut s = sess(); diff --git a/crates/engine/src/theme_pages_tests.rs b/crates/engine/src/theme_pages_tests.rs index e4c1071a..feaf4c6d 100644 --- a/crates/engine/src/theme_pages_tests.rs +++ b/crates/engine/src/theme_pages_tests.rs @@ -1,7 +1,7 @@ -//! The page colour of new files and pages follows the interface's theme when they are made (dark: -//! #1E1E1E, light: #F5F5F5) and is the page's own from then on; a session without an interface -//! (CLI, MCP, exports) has no theme and keeps the light default. Text drawn with the Text tool is -//! white on a dark page and black on a light one. +//! The page colour of a new file follows the interface's theme when it is made (dark: #1E1E1E, +//! light: #F5F5F5) and is the page's own from then on; an added page takes the colour of the page +//! it was added from. A session without an interface (CLI, MCP, exports) has no theme and keeps the +//! light default. Text drawn with the Text tool is white on a dark page and black on a light one. use figcraft_color::{Color, Paint}; use figcraft_doc::{NodeId, NodeKind}; @@ -38,8 +38,8 @@ fn draw_text(s: &mut Session, x: f64, y: f64) -> NodeId { } #[test] -fn new_files_and_pages_take_the_theme_they_are_made_in() { - // (theme the interface reported, colour of a new file's page and of an added page) +fn new_files_take_the_theme_they_are_made_in_and_added_pages_follow() { + // (theme the interface reported, colour of a new file's page and of a page added to it) for (dark, want) in [(None, "#F5F5F5"), (Some(false), "#F5F5F5"), (Some(true), "#1E1E1E")] { let mut s = Session::empty(); if let Some(d) = dark { @@ -62,8 +62,10 @@ fn a_change_of_theme_leaves_existing_pages_alone() { ex(&mut s, "file.new", json!({})); s.set_ui_dark(false); ex(&mut s, "page.add", json!({})); + assert_eq!(backgrounds(&s), ["#1E1E1E", "#1E1E1E"], "an added page follows its page, not the theme"); + ex(&mut s, "page.setBackground", json!({"color": "#F5F5F5"})); s.set_ui_dark(true); - assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps the colour it was made with"); + assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps its colour"); // A copy of a page is that page's colour, whatever the theme. let first = s.doc().unwrap().pages[0]; s.set_ui_dark(false); @@ -71,6 +73,57 @@ fn a_change_of_theme_leaves_existing_pages_alone() { assert_eq!(background(&s, copy), Color::DARK_PAGE); } +#[test] +fn an_added_page_takes_the_current_pages_colour() { + // Whatever the theme, and headless too: the page it was added from decides. + for dark in [None, Some(false), Some(true)] { + let mut s = Session::empty(); + if let Some(d) = dark { + s.set_ui_dark(d); + } + ex(&mut s, "file.new", json!({})); + ex(&mut s, "page.setBackground", json!({"color": "#336699"})); + ex(&mut s, "page.add", json!({})); + assert_eq!(backgrounds(&s), ["#336699", "#336699"], "theme {dark:?}"); + // From the second page, now current, after giving it its own colour. + ex(&mut s, "page.setBackground", json!({"color": "#FFE08A"})); + ex(&mut s, "page.add", json!({"name": "Third"})); + assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?}"); + // One undo step, the colour with it. + ex(&mut s, "edit.undo", json!({})); + assert_eq!(backgrounds(&s), ["#336699", "#FFE08A"], "theme {dark:?} after undo"); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?} after redo"); + } + // From a page other than the first: go there, then add. + let mut s = Session::new(); + s.set_ui_dark(true); + ex(&mut s, "page.add", json!({})); + ex(&mut s, "page.setBackground", json!({"color": "#0B1F3A"})); + ex(&mut s, "page.select", json!({"index": 0})); + ex(&mut s, "page.add", json!({})); + assert_eq!(backgrounds(&s), ["#1E1E1E", "#0B1F3A", "#1E1E1E"], "the current page decides, not the last"); +} + +#[test] +fn added_page_colours_survive_save_and_reopen() { + let dir = std::env::temp_dir().join(format!("figcraft-added-pages-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("pages.figcraft"); + let mut s = Session::new(); + ex(&mut s, "page.setBackground", json!({"color": "#336699"})); + ex(&mut s, "page.add", json!({})); + ex(&mut s, "file.saveAs", json!({"path": path.to_string_lossy()})); + let mut s = Session::empty(); + s.set_ui_dark(true); + ex(&mut s, "file.open", json!({"path": path.to_string_lossy()})); + assert_eq!(backgrounds(&s), ["#336699", "#336699"]); + // And a page added to the reopened file in the dark theme follows the file, not the theme. + ex(&mut s, "page.add", json!({})); + assert_eq!(backgrounds(&s), ["#336699", "#336699", "#336699"]); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn the_file_the_app_opened_with_takes_the_theme_once_it_is_known() { // Made before the interface knew its theme, nothing done to it yet. @@ -118,13 +171,14 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() { let t = draw_text(&mut s, 40.0, 40.0); assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(want)], "text drawn on {page}"); } - // In a frame on a dark page too; so is text a command makes in the app (`dark_page_text_tests`). + // In a (white) frame on a dark page, readable on the frame (#362); so is text a command makes + // in the app (`dark_page_text_tests`). let mut s = Session::empty(); s.set_ui_dark(true); ex(&mut s, "file.new", json!({})); ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200})); let t = draw_text(&mut s, 50.0, 50.0); - assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::WHITE)]); + assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::BLACK)]); let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap(); assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]); } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176d..ddd276f9 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -266,6 +266,12 @@ pub struct Overlay { /// Pen tool: the in-progress path's anchor points and the rubber band to the pointer. pub pen: Vec<[f64; 2]>, pub rubber: Option<[f64; 4]>, + /// Pen tool: the handles shaping the segments at the pen (anchor x, y, handle x, y): the last + /// anchor's in and out handles and the one before's out handle. + pub pen_handles: Vec<[f64; 4]>, + /// Pen tool: where the last anchor's out handle ends, when it has one: the rubber band is the + /// curve a click would make, leaving along it. + pub rubber_handle: Option<[f64; 2]>, /// Frame/section the pointer is drawing or dropping into (highlighted). pub drop_target: Option, /// Auto layout insertion indicator (x0, y0, x1, y1). @@ -397,6 +403,14 @@ pub(crate) enum Gesture { }, } +/// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in +/// and out handles and the one before's out handle, those that aren't zero. +fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> { + let last = points.last().into_iter().flat_map(|(p, i, o)| [(p, i), (p, o)]); + let before = points.len().checked_sub(2).and_then(|k| points.get(k)).into_iter().map(|(p, _, o)| (p, o)); + before.chain(last).filter(|(_, h)| h.hypot() > 0.0).map(|(p, h)| [p.x, p.y, p.x + h.x, p.y + h.y]).collect() +} + /// The pen tool's in-progress path (page coordinates). #[derive(Clone, Debug, Default)] pub struct PenPath { @@ -530,12 +544,16 @@ pub fn anchor_name(a: (u8, u8)) -> &'static str { ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl") } -/// The fill new text takes to be readable where it is drawn: white on a dark `page` (None: the -/// model's default, black). For text made in the app: by the Text tool, or by a command that -/// names no fill while the interface is up. -pub(crate) fn readable_text_fills(doc: &Document, page: NodeId) -> Option> { - matches!(doc.node(page).map(|p| &p.kind), Some(NodeKind::Page { background, .. }) if background.is_dark()) - .then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) +/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark +/// background (None: the model's default, black). The background is the nearest layer from `parent` +/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made +/// in the app: by the Text tool, or by a command that names no fill while the interface is up. +pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option> { + let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind { + NodeKind::Page { background, .. } => Some(background.is_dark()), + _ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()), + }); + dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) } const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"]; @@ -617,6 +635,18 @@ pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option< handle_at_corners(&corners, p, zoom, is_line) } +/// Half the side of the square the canvas draws on each corner of the handle box (screen pixels, +/// upright whatever the box's rotation). +const CORNER_SQUARE_PX: f64 = 4.0; + +/// Whether `p` is on a corner square of the selection's handle box as drawn: there the corner +/// resizes, although a corner-radius handle 10 px in reaches that far too. +fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool { + let reach = CORNER_SQUARE_PX / zoom.max(1e-6); + matches!(handle_at(doc, sel, p, zoom), Some(Handle::Corner(_))) + && selection_box(doc, sel).is_some_and(|c| c.iter().any(|c| (c.x - p.x).abs() <= reach && (c.y - p.y).abs() <= reach)) +} + /// A box whose sides are both shorter than this on screen (pixels) would be covered by its /// handles: the canvas draws just its outline (the handles still answer just outside it). pub const SMALL_BOX_PX: f64 = 16.0; @@ -685,7 +715,18 @@ pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor { Handle::Corner(i) => { let a = c.get(i as usize).copied().unwrap_or_default(); let b = c.get((i as usize + 2) % 4).copied().unwrap_or_default(); - Cursor::Resize(angle(a, b)) + // Along the box's own axes at 45°, not the diagonal, so wide or tall boxes still get a + // diagonal cursor. A box with no width or height falls back to the diagonal. + let unit = |v: figcraft_geom::kurbo::Vec2| { + let len = v.hypot(); + (len.is_finite() && len > 1e-9).then(|| v / len) + }; + let next = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); + let prev = c.get((i as usize + 3) % 4).copied().unwrap_or_default(); + match (unit(next - a), unit(prev - a)) { + (Some(u), Some(v)) => Cursor::Resize((u + v).atan2().to_degrees()), + _ => Cursor::Resize(angle(a, b)), + } } Handle::Edge(i) => { let a = c.get(i as usize).copied().unwrap_or_default(); @@ -982,7 +1023,11 @@ impl Session { let mut measure_box = None; let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; self.tool.overlay.arc_hover = arc_hover; - let shape_hover = if self.arc_handles_shown() { crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; + let shape_hover = if self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) { + crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) + } else { + None + }; self.tool.overlay.shape_hover = shape_hover; if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { cursor = Cursor::Text; @@ -1026,8 +1071,9 @@ impl Session { let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? }; guides = g; self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); - if let Some((last, _, _)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { + if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); + self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); } } self.tool.overlay.guides = guides; @@ -1090,6 +1136,7 @@ impl Session { return Ok(()); } if self.arc_handles_shown() + && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) && let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom) { self.begin_interaction("Corner radius")?; @@ -1164,7 +1211,13 @@ impl Session { if ids.is_empty() { return Ok(()); } - let targets = Targets::gather(&doc, page, &ids); + let mut targets = Targets::gather(&doc, page, &ids); + // A copy dragged out with Alt snaps to the layers it came from too (they stay put). + if ev.alt + && let Some(b) = targets.moving + { + targets.add_box(b); + } self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?; self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false }; self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; @@ -1675,6 +1728,7 @@ impl Session { // dropped; layers in an auto layout frame already sit in their slot. let page = self.page()?; let sel = self.selection(); + let sources = if duplicate { ids.clone() } else { Vec::new() }; let moving = if duplicate { sel } else { ids }; let doc = self.doc()?.clone(); let in_al = @@ -1703,6 +1757,10 @@ impl Session { self.cancel_interaction()?; } else { self.commit_interaction()?; + // Duplicate repeats a free Alt-drag's step (an auto layout places its own). + if duplicate && !in_al && moving.len() == sources.len() { + self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving }); + } } } else { self.cancel_interaction()?; @@ -1878,7 +1936,7 @@ impl Session { let at = Point::new(ev.x.round(), (ev.y - 7.0).round()); n.transform = pw.inverse() * Affine::translate(at.to_vec2()); n.name = String::new(); - if let Some(fills) = readable_text_fills(d, page) { + if let Some(fills) = readable_text_fills(d, parent) { n.fills = fills; } if let Some((w, h)) = figcraft_text::auto_size(&n) { @@ -1931,6 +1989,9 @@ impl Session { fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> { self.tool.overlay.guides.clear(); + // The segment being placed shows as itself while the press lasts. + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 }; let (doc, page) = { let ds = self.ds()?; @@ -1985,6 +2046,7 @@ impl Session { fn pen_update(&mut self) -> Result<()> { let Some(pen) = self.tool.pen.clone() else { return Ok(()) }; self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect(); + self.tool.overlay.pen_handles = pen_handles(&pen.points); if pen.points.len() < 2 { if let Ok(ds) = self.ds_mut() { ds.revision += 1; @@ -2067,7 +2129,9 @@ impl Session { pub fn pen_finish(&mut self) -> Result<()> { let pen = self.tool.pen.take(); self.tool.overlay.pen.clear(); + self.tool.overlay.pen_handles.clear(); self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; self.tool.gesture = Gesture::None; if pen.as_ref().is_some_and(|p| p.id.is_some()) { self.commit_interaction()?; diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index 3fbecb01..47270a42 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -277,6 +277,35 @@ fn constrain_proportions_keeps_the_other_side_in_ratio() { assert_eq!(size(&s, z), (20.0, 0.0), "a zero height leaves nothing to scale"); } +#[test] +fn a_press_anywhere_on_a_corner_handle_resizes() { + // The canvas draws each corner handle as an 8 px square centred on the corner, and the + // corner-radius handle sits 10 px in along the diagonal: a press on the square's inner half + // resizes (both sides, in ratio with the link on), it doesn't pick the radius handle. + let radii = |s: &Session, r: NodeId| s.doc().unwrap().node(r).unwrap().corners.radii; + for lock in [false, true] { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 1000.0, 600.0, None); + set(&mut s, &[r], json!({"constrainProportions": lock})); + ex(&mut s, "select.set", json!({"ids": [r]})); + let on_square = Point::new(997.0, 597.0); + s.pointer(&ev(PointerKind::Hover, on_square, NONE)).unwrap(); + assert!(matches!(s.tool.overlay.cursor, Cursor::Resize(_)), "lock {lock}: {:?}", s.tool.overlay.cursor); + drag(&mut s, on_square, Point::new(1197.0, 637.0), NONE); + let (w, h) = size(&s, r); + let want_h = if lock { 718.2 } else { 637.0 }; + assert!(near(w, 1197.0) && near(h, want_h), "lock {lock}: both sides follow the corner, got {w}×{h}"); + assert_eq!(radii(&s, r), [0.0; 4]); + } + // Further in, past the square, the radius handle still answers. + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 1000.0, 600.0, None); + ex(&mut s, "select.set", json!({"ids": [r]})); + drag(&mut s, Point::new(993.0, 593.0), Point::new(980.0, 580.0), NONE); + assert_eq!(size(&s, r), (1000.0, 600.0)); + assert!(radii(&s, r)[0] > 0.0, "the radius handle rounds the corners"); +} + #[test] fn frames_resize_with_constraints_and_cmd_ignores_them() { let mut s = session(); @@ -378,6 +407,30 @@ fn smart_guides_snap_to_edges_centres_and_equal_spacing() { assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0)); } +#[test] +fn corner_cursors_stay_diagonal_whatever_the_proportions() { + let mut s = session(); + let mod180 = |a: f64, b: f64| near((a - b).rem_euclid(180.0).min((b - a).rem_euclid(180.0)), 0.0); + for (w, h) in [(65.0, 15.0), (100.0, 100.0), (60.0, 200.0), (200.0, 50.0)] { + let r = rect(&mut s, 0.0, 0.0, w, h, None); + let doc = s.doc().unwrap().clone(); + let q = selection_box(&doc, &[r]).unwrap(); + for (i, want) in [(0u8, 45.0), (1, 135.0), (2, 45.0), (3, 135.0)] { + let Cursor::Resize(a) = handle_cursor(&q, Handle::Corner(i)) else { panic!() }; + assert!(mod180(a, want), "{w}x{h} corner {i}: {a}, want {want}"); + } + // Turned, the corner cursors turn with the layer's own axes. + set(&mut s, &[r], json!({"rotation": 30})); + let doc = s.doc().unwrap().clone(); + let q = selection_box(&doc, &[r]).unwrap(); + let axis = (q[1] - q[0]).atan2().to_degrees(); + for (i, off) in [(0u8, 45.0), (1, 135.0), (2, 45.0), (3, 135.0)] { + let Cursor::Resize(a) = handle_cursor(&q, Handle::Corner(i)) else { panic!() }; + assert!(mod180(a, axis + off), "{w}x{h} turned corner {i}: {a}, want {}", axis + off); + } + } +} + #[test] fn the_transform_box_and_its_handles() { let mut s = session(); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a5ae82ad..9f0b9f83 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -86,6 +86,12 @@ fn clip_attr(clip: &Option) -> String { clip.as_ref().map(|c| format!(" clip-path=\"url(#{c})\"")).unwrap_or_default() } +/// `el` in a group with `clip` (a ` clip-path` attribute, or nothing). A clip path is in page +/// coordinates, and on an element with a `transform` it would be read after that transform. +fn clipped(clip: &str, el: String) -> String { + if clip.is_empty() { el } else { format!("{el}") } +} + fn num(v: f64) -> String { if !v.is_finite() { return "0".into(); @@ -433,7 +439,8 @@ impl W<'_> { let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; for p in &n.fills { if let Some(a) = self.paint_attr(p, w, h) { - let _ = write!(self.body, "", matrix(xf), path_d(path)); + let el = format!("", matrix(xf), path_d(path)); + self.body.push_str(&clipped(&paint_clip, el)); } } } @@ -505,7 +512,7 @@ impl W<'_> { if n.has_visible_stroke() && let Some(simple) = self.opts.simplify_stroke.then(|| self.simple_stroke(n, xf)).flatten() { - self.body.push_str(&simple); + self.body.push_str(&clipped(&paint_clip, simple)); } else if n.has_visible_stroke() { let geom = match &n.kind { NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.as_ref().map(|(p, _)| p.clone()), @@ -517,7 +524,8 @@ impl W<'_> { let fr = if even { " fill-rule=\"evenodd\"" } else { "" }; for p in &n.strokes { if let Some(a) = self.paint_attr(p, w, h) { - let _ = write!(self.body, "", matrix(xf), path_d(&area)); + let el = format!("", matrix(xf), path_d(&area)); + self.body.push_str(&clipped(&paint_clip, el)); } } } diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 6c5bff97..360b95ca 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -43,3 +43,42 @@ fn import_gradient_and_groups() { assert!(kinds.contains(&"GROUP")); assert!(kinds.contains(&"RECTANGLE") && kinds.contains(&"ELLIPSE"), "{kinds:?}"); } + +/// #325: a frame with children inside a clipping frame keeps its own paint inside the clip. The +/// clip path is in page coordinates, so it must sit on a group around the transformed path, never +/// on the path itself (where it would be read after the path's transform, offset by it). +#[test] +fn nested_frame_paint_is_clipped_in_page_space() { + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let page = d.create(p, NodeKind::Frame, "Page", 0.0, 0.0, 400.0, 300.0).unwrap(); + d.get_mut(page).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())]; + let card = d.create(page, NodeKind::Frame, "Card", 40.0, 60.0, 300.0, 120.0).unwrap(); + let c = d.get_mut(card).unwrap(); + c.fills = vec![Paint::solid(Color::from_hex("DDDDDD").unwrap())]; + c.corners.radii = [12.0; 4]; + c.clip = true; + let badge = d.create(card, NodeKind::Frame, "Badge", 200.0, 40.0, 80.0, 40.0).unwrap(); + let b = d.get_mut(badge).unwrap(); + b.fills = vec![Paint::solid(Color::from_hex("1E6F8E").unwrap())]; + b.strokes = vec![Paint::solid(Color::from_hex("AA0000").unwrap())]; + b.stroke.weight = 2.0; + let dot = d.create(badge, NodeKind::Rectangle, "Dot", 10.0, 10.0, 20.0, 20.0).unwrap(); + d.get_mut(dot).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())]; + for simplify_stroke in [true, false] { + let svg = export_svg(&d, &[page], &SvgOptions { simplify_stroke, ..SvgOptions::default() }).unwrap(); + for tag in svg.split('<').filter(|t| t.contains(" clip-path=")) { + assert!(!tag.contains(" transform="), "clip-path on a transformed element: <{tag}\n{svg}"); + } + // The card's clip: its rounded outline starts after the top-left radius. + let def = svg.find(""); + for color in ["#1E6F8E", "#AA0000"] { + let at = svg.find(color).unwrap_or_else(|| panic!("{color} missing\n{svg}")); + let path = svg[..at].rfind("= line.char_end { - self.line_end_x(line) + if line.para_end && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } + // The last line has no paragraph break to show, so it ends at its glyphs. + self.line_end_x(line) + if line.para_end && li + 1 < self.lines.len() && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } } else { self.caret(b).0 }; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 526ae0b0..e34f0110 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -416,3 +416,24 @@ fn arrows_move_visually_through_right_to_left_runs() { // Plain left-to-right lines step by char (the caller's default). assert!(layout(&TextData::new("plain"), None, None).visual_step(1, true).is_none()); } + +#[test] +fn selection_through_text_end_stays_inside_auto_width_box() { + if !have_fonts() { + return; + } + for size in [12.0, 24.0, 96.0] { + let mut t = TextData::new("Hello world"); + t.style.font_size = size; + let l = layout(&t, None, None); + let n = "Hello world".chars().count(); + let right = l.selection_rects(0, n).last().map_or(f64::NAN, |r| r.x1); + assert!(right <= l.width.ceil(), "size {size}: right {right} > ceil(width) {}", l.width.ceil()); + } + // A selected paragraph break still shows past the end of its line. + let t = TextData::new("Hello\nworld"); + let l = layout(&t, None, None); + let r = l.selection_rects(0, 6); + let first = r.first().map_or(f64::NAN, |r| r.x1); + assert!(first > l.caret(5).0 + 1.0, "{first}"); +} diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 7552cca3..affda97d 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{bin_added} / −{bin_deleted} Binärdateien, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Eine Sammlung dieser Datei mit diesen Modi und Variablen: Überschreiben Sie ihre Werte je Modus und fügen Sie eigene Variablen hinzu" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Eine einzelne Ebene richtet sich an ihrem Rahmen aus: Wählen Sie weitere Ebenen oder eine Ebene in einem Rahmen" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Eine Feder läuft, bis sie zur Ruhe kommt: Masse, Steifigkeit und Dämpfung bestimmen die Dauer" "About" = "Über" "About FigCraft" = "Über FigCraft" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c35b1677..08ac64ba 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -16,6 +16,7 @@ name = "English" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "A layer on its own aligns to its frame: select more layers, or one in a frame" "A responsive product page: hero, features, figures, a quote and a footer" = "A responsive product page: hero, features, figures, a quote and a footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long" "A title, an agenda, a big figure, a chart and a closing slide" = "A title, an agenda, a big figure, a chart and a closing slide" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6514ac7b..3200e891 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{bin_added} / −{bin_deleted} recursos binarios, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Una colección de este archivo con estos modos y variables: modifica sus valores por modo y añade variables propias" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Una capa sola se alinea con su marco: selecciona más capas o una dentro de un marco" "A responsive product page: hero, features, figures, a quote and a footer" = "Una página de producto adaptable: cabecera, funciones, cifras, una cita y un pie" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Un muelle se reproduce hasta que se asienta: su masa, rigidez y amortiguación fijan cuánto dura" "A title, an agenda, a big figure, a chart and a closing slide" = "Un título, una agenda, una gran cifra, un gráfico y una diapositiva de cierre" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index c8665934..69798340 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{bin_added} / −{bin_deleted} ressources binaires, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée" "About" = "À propos" "About FigCraft" = "À propos de FigCraft" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index f7b9cf9f..23a0f5e2 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -19,6 +19,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:PR {prs} 件、コミット {commits} 件、+{added} / −{deleted} 行(Δ {delta})、バイナリアセット +{bin_added} / −{bin_deleted}、{first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "このファイルのコレクションとそのモード・変数:モードごとに値を上書きし、独自の変数を追加できる" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "単独のレイヤーはフレームに整列します:レイヤーを追加で選択するか、フレーム内のレイヤーを選択" "A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる" "About" = "情報" "About FigCraft" = "FigCraft について" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 59bb2386..aed3529a 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -18,6 +18,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR {prs}개, 커밋 {commits}개, +{added} / −{deleted}줄 (Δ {delta}), 바이너리 에셋 +{bin_added} / −{bin_deleted}, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "이 파일의 컬렉션과 그 모드 및 변수: 모드별로 값을 재정의하고 자체 변수를 추가할 수 있음" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "레이어 하나는 프레임에 맞춰 정렬됩니다: 레이어를 더 선택하거나 프레임 안의 레이어를 선택하세요" "A spring plays until it settles: its mass, stiffness and damping set how long" = "스프링은 멈출 때까지 재생됨. 길이는 질량, 강성, 감쇠로 결정됨" "About" = "정보" "About FigCraft" = "FigCraft 정보" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index a692a8ce..97500236 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -16,6 +16,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{bin_added} / −{bin_deleted} arquivos binários, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Uma coleção deste arquivo com estes modos e variáveis: substitua os valores por modo e adicione variáveis próprias" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Uma camada sozinha se alinha ao seu quadro: selecione mais camadas ou uma dentro de um quadro" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Uma mola se move até se estabilizar: sua massa, rigidez e amortecimento definem a duração" "About" = "Sobre" "About FigCraft" = "Sobre o FigCraft" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d2431df0..d1887735 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -19,6 +19,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:{prs} 个 PR,{commits} 次提交,+{added} / −{deleted} 行(Δ {delta}),二进制资源 +{bin_added} / −{bin_deleted},{first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "本文件中带有这些模式和变量的集合:可按模式覆盖其值,并添加自己的变量" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "单个图层与其所在画框对齐:请再选择其他图层,或选择画框内的图层" "A spring plays until it settles: its mass, stiffness and damping set how long" = "弹簧动画播放至静止,时长由质量、刚度和阻尼决定" "About" = "关于" "About FigCraft" = "关于 FigCraft" diff --git a/crates/ui-egui/src/a11y_tests.rs b/crates/ui-egui/src/a11y_tests.rs index 3fe85876..d041a5b3 100644 --- a/crates/ui-egui/src/a11y_tests.rs +++ b/crates/ui-egui/src/a11y_tests.rs @@ -388,3 +388,18 @@ fn every_accessibility_path_runs_with_the_tree_on_without_nested_context_locks() let out = u.frame_output(vec![]); assert!(out.platform_output.accesskit_update.is_some()); } + +#[test] +fn the_align_buttons_are_off_for_a_layer_alone_on_the_page() { + let (mut u, a, b) = setup(); + let align_left = |nodes: &[Node]| { + nodes.iter().find(|n| n.label().is_some_and(|l| l.starts_with("Align left"))).map(Node::is_disabled).expect("the Align left button") + }; + assert!(align_left(&tree(&mut u)), "Rect A alone on the page: nothing to align to"); + u.app.run("select.set", json!({"ids": [a, b]})); + assert!(!align_left(&tree(&mut u)), "two layers align to each other"); + let f = id(u.app.session.execute("frame.create", &json!({"x": 300, "y": 0, "width": 100, "height": 100})).unwrap()); + let inner = id(u.app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap()); + u.app.run("select.set", json!({"ids": [inner]})); + assert!(!align_left(&tree(&mut u)), "one in a frame aligns to it"); +} diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 37485568..5ea19375 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -190,15 +190,27 @@ fn same_layers(a: &Document, b: &Document, id: NodeId) -> bool { } pub(crate) fn thumbnail(ctx: &egui::Context, doc: &Document, id: NodeId, px: f32) -> Option { - let b = figcraft_render::export_bounds(doc, id)?; + let pix = thumbnail_pixmap(doc, id, px)?; + let img = crate::render_worker::screen_image(&pix, doc.color_profile); + Some(ctx.load_texture(format!("asset-thumb-{}", id.0), img, egui::TextureOptions::LINEAR)) +} + +/// The pixels a thumbnail shows. A slice paints nothing itself, so it shows the page content +/// under it, exactly as exporting one does (`figcraft_engine::cmd::file::drawn_layers`): without +/// this a slice's preview is a blank checkerboard next to a correct export. +pub(crate) fn thumbnail_pixmap(doc: &Document, id: NodeId, px: f32) -> Option { + let slice = doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Slice)); + let b = if slice { doc.world_bounds(id)? } else { figcraft_render::export_bounds(doc, id)? }; let side = b.width().max(b.height()); if !(side.is_finite() && side > 0.0) { return None; } let scale = (f64::from(px) / side).min(2.0); - let pix = figcraft_render::render_node(doc, id, scale)?; - let img = crate::render_worker::screen_image(&pix, doc.color_profile); - Some(ctx.load_texture(format!("asset-thumb-{}", id.0), img, egui::TextureOptions::LINEAR)) + if slice { + let layers = doc.page_of(id).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default(); + return figcraft_render::render_area(doc, &layers, b, scale); + } + figcraft_render::render_node(doc, id, scale) } fn matches(name: &str, q: &str) -> bool { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb23c8de..beec9aa3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -973,12 +973,30 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if app.session.tool.tool == ToolId::Pencil { painter.add(Shape::line(pts, stroke(t.text, 2.0))); } else { + for [x0, y0, x1, y1] in &ov.pen_handles { + bezier_handle(painter, app.to_screen(*x0, *y0), app.to_screen(*x1, *y1), 3.5, Color32::WHITE); + } for p in &pts { painter.circle_filled(*p, 3.5, Color32::WHITE); painter.circle_stroke(*p, 3.5, stroke(ACCENT, 1.0)); } if let Some([x0, y0, x1, y1]) = ov.rubber { - painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0)); + let (a, b) = (app.to_screen(x0, y0), app.to_screen(x1, y1)); + // The segment a click would make: along the last anchor's out handle, if it has one. + match ov.rubber_handle { + Some([hx, hy]) => { + let h = app.to_screen(hx, hy); + painter.add(egui::epaint::CubicBezierShape::from_points_stroke( + [a, h, b, b], + false, + Color32::TRANSPARENT, + stroke(ACCENT, 1.0), + )); + } + None => { + painter.line_segment([a, b], stroke(ACCENT, 1.0)); + } + } } } } @@ -1111,6 +1129,14 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig } } +/// A bezier handle from its anchor `v` to its end `e` (screen points): the line, and a dot of +/// radius `r` filled with `fill` (vector edit mode and the Pen draw them alike). +fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color32) { + painter.line_segment([v, e], stroke(ACCENT, 1.0)); + painter.circle_filled(e, r, fill); + painter.circle_stroke(e, r, stroke(ACCENT, 1.0)); +} + /// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and /// for the `active` layer the closed area under the paint bucket and the pen's rubber band. fn vector_overlay( @@ -1151,13 +1177,9 @@ fn vector_overlay( return; } for h in &o.handles { - let (v, e) = (sp(&h.vertex), sp(&h.handle)); - painter.line_segment([v, e], stroke(ACCENT, 1.0)); let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start }); - let r = if hovered { 4.5 } else { 3.5 }; let on = o.selected_handles.contains(&(h.seg, h.start)); - painter.circle_filled(e, r, if on { ACCENT } else { Color32::WHITE }); - painter.circle_stroke(e, r, stroke(ACCENT, 1.0)); + bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { ACCENT } else { Color32::WHITE }); } for (i, v) in o.vertices.iter().enumerate() { let p = sp(v); diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 37b03e55..5a36b712 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -1,5 +1,6 @@ //! Canvas fixes, driven through egui headless: View ▸ Frame outlines and View ▸ Slices draw (off -//! by default), and the render worker counts a job as in flight until it is done. +//! by default), the render worker counts a job as in flight until it is done, and the Pen shows +//! the handles of the path it draws. use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -91,3 +92,36 @@ fn a_job_queued_behind_a_finished_one_stays_in_flight() { // Not in flight only once the second is done: its image is then waiting. assert!(worker.in_flight() || worker.poll().is_some(), "the second job counts until its image is there"); } + +#[test] +fn the_pen_draws_the_handles_of_the_point_being_dragged_and_a_curved_band() { + use egui::{Event, PointerButton}; + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "pen"})); + let at = |u: &Ui, x: f64, y: f64| u.app.to_screen(x, y); + let press = |p: Pos2, pressed: bool| { + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }] + }; + let accent = egui::epaint::ColorMode::Solid(crate::theme::ACCENT); + // A handle: the line from its anchor and the dot at its end. + let handle = |shapes: &[Shape], a: Pos2, e: Pos2| { + let near = |p: Pos2, q: Pos2| p.distance(q) < 0.5; + let line = shapes.iter().any(|s| matches!(s, Shape::LineSegment { points: [p, q], .. } if near(*p, a) && near(*q, e))); + let dot = shapes.iter().any(|s| matches!(s, Shape::Circle(c) if near(c.center, e) && c.fill == egui::Color32::WHITE)); + line && dot + }; + let a = at(&u, 100.0, 100.0); + u.frame(press(a, true)); + for k in 1..=4 { + u.frame(vec![Event::PointerMoved(at(&u, 100.0 + 10.0 * f64::from(k), 100.0))]); + } + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(handle(&shapes, a, at(&u, 140.0, 100.0)), "the out handle while dragging"); + assert!(handle(&shapes, a, at(&u, 60.0, 100.0)), "and the in handle opposite"); + u.frame(press(at(&u, 140.0, 100.0), false)); + // After release, towards the next point: the band curves along the out handle. + let shapes = flat(&u.frame_output(vec![Event::PointerMoved(at(&u, 200.0, 200.0))]).shapes); + assert!(handle(&shapes, a, at(&u, 140.0, 100.0)), "the handles stay"); + let band = shapes.iter().any(|s| matches!(s, Shape::CubicBezier(c) if c.points[0].distance(a) < 0.5 && c.points[1].distance(at(&u, 140.0, 100.0)) < 0.5 && c.stroke.color == accent)); + assert!(band, "a curve leaving along the out handle"); +} diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 9a112367..e55130c5 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -438,10 +438,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ) { let kinds = [ (Icon::Fill, tr("Solid")), - (Icon::LayoutH, tr("Linear")), - (Icon::Ellipse, tr("Radial")), - (Icon::Rotate, tr("Angular")), - (Icon::Instance, tr("Diamond")), + (Icon::GradientLinear, tr("Linear")), + (Icon::GradientRadial, tr("Radial")), + (Icon::GradientAngular, tr("Angular")), + (Icon::GradientDiamond, tr("Diamond")), (Icon::Image, tr("Image")), (Icon::ViewGrid, tr("Pattern")), ]; diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs new file mode 100644 index 00000000..3d536a01 --- /dev/null +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -0,0 +1,52 @@ +//! The Export section's Preview row: a slice paints nothing itself, so its preview must show the +//! page content under it — the same thing exporting the slice writes — and not a blank +//! transparency checkerboard next to a correct export. + +use figcraft_doc::{Document, NodeId}; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::assets_panel::thumbnail_pixmap; + +/// Run `commands`, returning the document and the id each one made. +fn doc_after(commands: &[(&str, Value)]) -> (Document, Vec) { + let mut s = Session::new(); + let ids = commands.iter().map(|(cmd, params)| NodeId(s.execute(cmd, params).unwrap()["id"].as_u64().unwrap())).collect(); + (s.doc().unwrap().clone(), ids) +} + +/// Whether the pixmap paints anything (any pixel not fully transparent). +fn paints(pix: &figcraft_render::Pixmap) -> bool { + figcraft_render::to_rgba8(pix).as_chunks::<4>().0.iter().any(|p| p.get(3).copied().unwrap_or(0) > 0) +} + +#[test] +fn a_slice_previews_the_content_under_it() { + let (doc, ids) = doc_after(&[ + ("shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100})), + ("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100})), + ]); + let slice = ids[1]; + let pix = thumbnail_pixmap(&doc, slice, 200.0).expect("the slice previews"); + assert_eq!([pix.width(), pix.height()], [200, 100], "the slice's area, not an empty node"); + assert!(paints(&pix), "the rectangle under the slice shows"); +} + +#[test] +fn a_slice_with_nothing_under_it_is_blank() { + let (doc, ids) = doc_after(&[("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100}))]); + let pix = thumbnail_pixmap(&doc, ids[0], 200.0).expect("the preview still has the slice's area"); + assert_eq!([pix.width(), pix.height()], [200, 100]); + assert!(!paints(&pix), "nothing under it stays transparent"); +} + +#[test] +fn other_layers_preview_themselves() { + let (doc, ids) = doc_after(&[ + ("shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100})), + ("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100})), + ]); + let pix = thumbnail_pixmap(&doc, ids[0], 200.0).expect("the rectangle previews"); + assert_eq!([pix.width(), pix.height()], [200, 100], "the layer's own area"); + assert!(paints(&pix)); +} diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 9b57cb0a..e6c78947 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -88,26 +88,49 @@ pub(crate) struct FileRow<'a> { pub detail_ink: Color32, } -/// Paint `row` in `r` (highlighted while `resp` is hovered): the name gives way to the detail. -pub(crate) fn file_row(ui: &Ui, t: &Tokens, r: Rect, resp: &Response, row: &FileRow) { +/// Paint `row` in `r` (highlighted while `resp` is hovered): the name first, the detail in the +/// room left after it, cut at its start; true when either is cut. +pub(crate) fn file_row(ui: &Ui, t: &Tokens, r: Rect, resp: &Response, row: &FileRow) -> bool { + /// Between the name and the detail. + const GAP: f32 = 16.0; + /// A detail with less room than this is left out rather than shown as a bare ellipsis. + const MIN_DETAIL: f32 = 48.0; if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } if let Some((icon, tint)) = row.mark { icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 8.0), vec2(16.0, 16.0)), icon, tint); } - let detail = ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, row.detail, regular(row.detail_size), row.detail_ink); - let name = widgets::elide_font(ui, row.name, (detail.min.x - r.min.x - 44.0).max(40.0), regular(12.0)); - ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, name, regular(12.0), row.ink); + let (name_font, detail_font) = (regular(12.0), regular(row.detail_size)); + let width = |s: &str, font: &egui::FontId| ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x; + let room = (r.width() - 40.0).max(0.0); + let name_w = width(row.name, &name_font); + let detail_w = width(row.detail, &detail_font); + // When both don't fit, the name keeps at least 60% of the row and the detail gets the rest. + let name_max = if name_w + GAP + detail_w <= room { name_w } else { name_w.min((room - GAP - detail_w).max(room * 0.6)) }; + let name = widgets::elide_font(ui, row.name, name_max, name_font.clone()); + let detail_room = room - width(&name, &name_font) - GAP; + let detail = if row.detail.is_empty() || detail_room < MIN_DETAIL { + String::new() + } else { + widgets::elide_start_font(ui, row.detail, detail_room, detail_font.clone()) + }; + let cut = name != row.name || detail != row.detail; + ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, name, name_font, row.ink); + ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, detail, detail_font, row.detail_ink); + cut } /// A row of Recents' own lists: laid out in place, named for screen readers, ringed with the /// keyboard on it; true when chosen. fn listed(ui: &mut Ui, t: &Tokens, id: Id, height: f32, row: &FileRow) -> bool { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().min(640.0), height), Sense::hover()); - let resp = ui.interact(r, id, Sense::click()); + let mut resp = ui.interact(r, id, Sense::click()); a11y::button(&resp, row.name); - file_row(ui, t, r, &resp, row); + if file_row(ui, t, r, &resp, row) { + // Cut to fit: in full on hover. + resp = resp.on_hover_text(format!("{}\n{}", row.name, row.detail)); + } focus_ring(ui, t, r, &resp, 5); resp.clicked() } diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index dbc58506..fc148e81 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -319,8 +319,16 @@ pub struct Recents { pub selected: Option, /// The pass the recent files were last drawn in: coming back to them reads the dates again. drawn: u64, + /// When the dates were last read again while in view, and whether the window had the focus: + /// files deleted, moved or saved elsewhere show up within [`RECHECK_SECS`], or at once when + /// the window gets the focus back. + checked: f64, + focused: bool, } +/// How often the dates of the recent files in view are read again (seconds). +const RECHECK_SECS: f64 = 2.0; + enum Job { Stat(String), Preview(String, Option), @@ -423,10 +431,15 @@ impl Recents { /// files not looked at since the recent files came into view. `time`: the input time. fn refresh(&mut self, ctx: &egui::Context, paths: &[String], time: f64) { let pass = ctx.cumulative_pass_nr(); - if pass > self.drawn.saturating_add(1) { + let (focused, refocused) = ctx.input(|i| (i.focused, i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(true))))); + let back = pass > self.drawn.saturating_add(1) || refocused || (focused && !self.focused); + if back || time - self.checked >= RECHECK_SECS { self.files.values_mut().for_each(|k| k.asked = false); + self.checked = time; } self.drawn = pass; + self.focused = focused; + ctx.request_repaint_after(std::time::Duration::from_secs_f64(RECHECK_SECS)); let loader = self.loader.get_or_insert_with(|| Loader::new(ctx)); for reply in loader.poll() { match reply { diff --git a/crates/ui-egui/src/home_recents_tests.rs b/crates/ui-egui/src/home_recents_tests.rs index 6c8bf0b6..1c3235dd 100644 --- a/crates/ui-egui/src/home_recents_tests.rs +++ b/crates/ui-egui/src/home_recents_tests.rs @@ -533,3 +533,38 @@ fn the_home_screen_draws_no_frames_once_the_pictures_are_in() { assert!(delay >= Duration::from_secs(1), "a frame in {delay:?}: {:?}", h.ctx.repaint_causes()); assert_eq!(file(&h.app.inspect()["homeRecents"], "pic")["picture"], "picture"); } + +#[test] +fn files_deleted_while_in_view_show_as_gone() { + let (_, paths) = files("deleted"); + let (mut h, _) = crate::live_tests::Harness::new(); + h.app.session.prefs.recent_files = paths.clone(); + h.app.session.prefs.getting_started_dismissed = true; + h.app.ui.home = true; + let exists = |h: &mut crate::live_tests::Harness, name: &str| file(&h.app.inspect()["homeRecents"], name)["exists"].clone(); + // Up to `n` frames (1/60 s of UI time each), giving the reader thread time between them. + let wait_n = |h: &mut crate::live_tests::Harness, name: &str, want: bool, n: usize| { + for _ in 0..n { + h.frame(vec![]); + if exists(h, name) == want { + return true; + } + std::thread::sleep(Duration::from_millis(5)); + } + false + }; + let wait = |h: &mut crate::live_tests::Harness, name: &str, want: bool| wait_n(h, name, want, 400); + assert!(wait(&mut h, "pic", true) && wait(&mut h, "art", true)); + // Deleted while the recent files stay in view: within a couple of seconds. + std::fs::remove_file(&paths[0]).unwrap(); + h.frames(5); + assert_eq!(exists(&mut h, "pic"), true, "not read again on every frame"); + h.skip(2.5); + assert!(wait(&mut h, "pic", false), "read again after a while"); + // Deleted while the window was in the background: as soon as it has the focus again. + std::fs::remove_file(&paths[2]).unwrap(); + h.frame(vec![egui::Event::WindowFocused(false)]); + h.frame(vec![egui::Event::WindowFocused(true)]); + // Well under the regular interval, so only the focus can have caused it. + assert!(wait_n(&mut h, "art", false, 30), "read again on getting the focus back"); +} diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index f2091f01..af651992 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -4,14 +4,14 @@ //! started card (closed, kept closed, and brought back from the Help menu). use egui::accesskit::Role; -use egui::{Event, Key, Modifiers, PointerButton, Pos2, Shape}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape}; use figcraft_engine::Session; use figcraft_engine::templates::TEMPLATES; use serde_json::json; use crate::home::{Section, cached_thumbnails}; use crate::layers_drag_tests::Ui; -use crate::live_tests::{flat, has_text, text_center}; +use crate::live_tests::{flat, has_text, text_center, texts}; /// The app as a first run finds it: no documents, no recent files. fn first_run() -> Ui { @@ -233,6 +233,42 @@ fn a_first_run_offers_templates_on_recents_and_recent_files_replace_them() { assert!(has_text(&s, "Open files"), "the document opened from the template is listed"); } +#[test] +fn an_open_files_long_name_and_path_share_their_row_without_overlapping() { + let name = "Quarterly planning: onboarding flow review with annotations and handoff notes"; + let long = "/Users/alex/Documents/Clients/Northwind Traders/2026 Website relaunch/Design reviews/Week 42/onboarding-flow-review.figcraft"; + let short = "/tmp/wireframes.figcraft"; + let mut s = Session::new(); + s.execute("file.rename", &json!({"name": name})).unwrap(); + s.docs[0].path = Some(long.into()); + s.execute("file.new", &json!({"name": "Wireframes"})).unwrap(); + s.docs[1].path = Some(short.into()); + let mut u = Ui::new(s); + u.app.ui.home = true; + u.app.session.prefs.getting_started_dismissed = true; + let s = settle(&mut u); + assert!(has_text(&s, "Open files")); + // A row that fits shows its name and path in full. + assert!(has_text(&s, "Wireframes") && has_text(&s, short)); + // One that doesn't: the name, then the end of its path, side by side. + let all = texts(&s); + let rect = |(_, p, size): &(String, Pos2, egui::Vec2)| Rect::from_min_size(*p, *size); + let detail = all.iter().find(|(t, _, _)| t.ends_with("/onboarding-flow-review.figcraft")).expect("the path"); + let detail_rect = rect(detail); + let shown_name = + all.iter().find(|e| e.0.starts_with('Q') && (rect(e).center().y - detail_rect.center().y).abs() < 2.0).expect("the name on the path's row"); + let name_rect = rect(shown_name); + assert!(shown_name.0.starts_with("Quarterly planning: onboarding") && shown_name.0.ends_with('…'), "{:?}", shown_name.0); + assert!(name_rect.max.x + 12.0 <= detail_rect.min.x, "{:?} {name_rect:?} overlaps {:?} {detail_rect:?}", shown_name.0, detail.0); + assert!(detail.0.starts_with('…') && long.ends_with(detail.0.trim_start_matches('…')), "{:?}", detail.0); + // Hovered, the row shows them in full. + u.frame(vec![Event::PointerMoved(name_rect.center())]); + for _ in 0..60 { + u.frame(vec![]); + } + assert!(texts(&shapes(&mut u)).iter().any(|(t, _, _)| t.contains(long)), "no tooltip with the full path"); +} + #[test] fn the_getting_started_card_closes_stays_closed_and_comes_back_from_help() { let mut u = first_run(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 726f62f4..867b0894 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -120,6 +120,11 @@ icons! { Export, StrokeIcon, Fill, + /// Gradient kinds: a swatch outline holding a fade of that kind. + GradientLinear, + GradientRadial, + GradientAngular, + GradientDiamond, Sun, Moon, Undo, @@ -311,6 +316,32 @@ impl P<'_> { // A hairline around the mesh gives it the same soft edge as the convex fills. self.painter.add(Shape::closed_line(v, Stroke::new(self.px, self.color))); } + /// Triangles `tris` over `verts` (x, y, alpha): the colour fades between the vertices. + fn shade(&self, verts: &[(f32, f32, f32)], tris: &[u32]) { + let mut mesh = Mesh::default(); + for &(x, y, a) in verts { + mesh.colored_vertex(self.raw(x, y), self.color.gamma_multiply(a)); + } + mesh.indices = tris.to_vec(); + self.painter.add(Shape::mesh(mesh)); + } + /// A disc of radius `r` at the centre, as a fan from the top, clockwise: each slice's rim + /// alphas from `rim` and its centre's from `centre`, both of how far round it is (0..1). + fn shade_fan(&self, r: f32, centre: impl Fn(f32) -> f32, rim: impl Fn(f32) -> f32) { + const SLICES: u32 = 32; + let at = |t: f32| { + let a = -FRAC_PI_2 + t * TAU; + (8.0 + r * a.cos(), 8.0 + r * a.sin()) + }; + let mut verts = Vec::new(); + for i in 0..SLICES { + let (t0, t1) = (i as f32 / SLICES as f32, (i + 1) as f32 / SLICES as f32); + let ((x0, y0), (x1, y1)) = (at(t0), at(t1)); + verts.extend([(8.0, 8.0, centre((t0 + t1) / 2.0)), (x0, y0, rim(t0)), (x1, y1, rim(t1))]); + } + let tris: Vec = (0..SLICES * 3).collect(); + self.shade(&verts, &tris); + } fn rect(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32) { let rr = Rect::from_min_max(self.sp(x0, y0), self.sp(x1, y1)); self.painter.rect_stroke(rr, egui::CornerRadius::same((r * self.s).round() as u8), self.stroke, egui::StrokeKind::Middle); @@ -833,6 +864,24 @@ fn draw(p: &P, icon: Icon) { } StrokeIcon => p.rect(2.5, 2.5, 13.5, 13.5, 2.0), Fill => p.fill_rect(2.0, 2.0, 14.0, 14.0, 2.0), + GradientLinear => { + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + p.shade(&[(4.0, 4.0, 1.0), (12.0, 4.0, 0.0), (12.0, 12.0, 0.0), (4.0, 12.0, 1.0)], &[0, 1, 2, 0, 2, 3]); + } + GradientRadial => { + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + p.shade_fan(5.5, |_| 1.0, |_| 0.0); + } + GradientAngular => { + // Once round from the top: the seam shows where the sweep starts. + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + p.shade_fan(4.0, |t| 1.0 - t, |t| 1.0 - t); + } + GradientDiamond => { + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + let v = [(8.0, 8.0, 1.0), (8.0, 2.5, 0.0), (13.5, 8.0, 0.0), (8.0, 13.5, 0.0), (2.5, 8.0, 0.0)]; + p.shade(&v, &[0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1]); + } Moon => { // A crescent: the outer rim, then the shadowing circle's edge back. Both arcs stop short // of where the circles meet, so each tip is two blunt joins (a single cusp join folds). @@ -1102,6 +1151,28 @@ mod tests { assert!(out.shapes.len() > Icon::ALL.len() * 10); } + /// The gradient kinds' icons show a fade: a mesh whose vertices run from opaque to clear. + #[test] + fn gradient_icons_fade() { + let ctx = egui::Context::default(); + for icon in [Icon::GradientLinear, Icon::GradientRadial, Icon::GradientAngular, Icon::GradientDiamond] { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, Vec2::splat(16.0)), icon, Color32::WHITE); + }); + out.textures_delta.clear(); + let alphas: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + Shape::Mesh(m) => Some(m.vertices.iter().map(|v| v.color.a()).collect::>()), + _ => None, + }) + .flatten() + .collect(); + assert!(alphas.contains(&255) && alphas.contains(&0), "{icon:?} fades from opaque to clear: {alphas:?}"); + } + } + #[test] fn concave_polygons_triangulate() { let arrow = [pos2(4.0, 2.0), pos2(4.0, 13.6), pos2(6.9, 10.9), pos2(8.8, 14.8), pos2(10.6, 14.0), pos2(8.7, 10.2), pos2(12.6, 10.2)]; diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 956709f4..927e3ae4 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -241,9 +241,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } if renaming { let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); - if let Some(new) = - crate::widgets::text_field_inline(ui, Id::new(("pagename", id)), &name, tr, if id == cur { semibold(11.0) } else { regular(11.0) }) - { + let start = ui.data_mut(|d| d.remove_temp::(page_rename_start_id())).unwrap_or(false); + let font = if id == cur { semibold(11.0) } else { regular(11.0) }; + if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new(("pagename", id)), &name, tr, font, start) { rename_done = Some((id, new)); } } else if !divider { @@ -255,7 +255,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } if resp.double_clicked() { app.ui.rename_page = Some((id, name.clone())); - ui.memory_mut(|m| m.request_focus(Id::new(("pagename", id)).with("te"))); + // The field takes the focus next frame, once it's drawn: focusing it now would put a + // node that isn't there yet in the screen reader tree, which AccessKit aborts on. + ui.data_mut(|d| d.insert_temp(page_rename_start_id(), true)); } crate::right::context_popup(&resp).show(|ui| { if ui.button(tr("Rename page")).clicked() { @@ -295,6 +297,11 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { crate::widgets::separator(ui); } +/// A page rename just asked for by a double click: its field takes the focus when drawn (temp data). +fn page_rename_start_id() -> Id { + Id::new("page_rename_start") +} + /// The layer row being dragged in the panel (temp data). fn layer_drag_id() -> Id { Id::new("layer_drag") diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index e1fdecc4..155f0d6d 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -4,7 +4,7 @@ //! list (dragging pages, dividers). Rows the documentation is silent on say so and pin today's //! behaviour. What the gestures do to the document is in the engine's `panels_behaviour_tests`. -use egui::{Event, Modifiers, Pos2}; +use egui::{Event, Id, Modifiers, Pos2}; use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; @@ -226,3 +226,44 @@ fn pages_drag_to_reorder_and_dividers_open_nothing() { assert_eq!(u.app.session.page().unwrap(), p1, "clicking a divider opens nothing"); assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } + +/// A double click at `p`, with the screen reader tree checked on every frame: the platform adapter +/// (UI Automation on Windows) aborts the app when the focused node isn't in the tree. +fn double_click_checking_focus(u: &mut Ui, p: Pos2) { + for _ in 0..30 { + u.frame(vec![Event::PointerMoved(p)]); + } + let presses = [true, false, true, false].into_iter().map(Some); + for (k, pressed) in presses.chain(std::iter::repeat_n(None, 30)).enumerate() { + let events = + pressed.map(|pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + let out = u.frame_output(events.into_iter().collect()); + let tree = out.platform_output.accesskit_update.expect("an AccessKit update"); + assert!(tree.nodes.iter().any(|(i, _)| *i == tree.focus), "frame {k}: the focused node {:?} isn't in the tree", tree.focus); + } +} + +#[test] +fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() { + let mut s = Session::new(); + let p1 = s.page().unwrap(); + let p2 = id(s.execute("page.add", &json!({})).unwrap()); + let mut u = Ui::new(s); + let (r1, r2) = (page_row(&mut u, p1, p2), page_row(&mut u, p2, p1)); + u.ctx.enable_accesskit(); + // Another page: the first click opens it, the second renames it. + double_click_checking_focus(&mut u, r2); + assert_eq!(u.app.session.page().unwrap(), p2); + assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p2), "a double click renames the page"); + assert!(u.ctx.memory(|m| m.has_focus(Id::new(("pagename", p2)).with("te"))), "with the keyboard in its name"); + // The page that's open. + u.app.ui.rename_page = None; + u.ctx.memory_mut(|m| m.surrender_focus(Id::new(("pagename", p2)).with("te"))); + u.app.session.execute("page.select", &json!({"id": p1})).unwrap(); + for _ in 0..30 { + u.frame(vec![]); + } + double_click_checking_focus(&mut u, r1); + assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p1)); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..dfa5b4bb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,6 +63,9 @@ pub mod versions; pub mod widgets; pub mod windows; +#[cfg(test)] +mod theme_startup_tests; + #[cfg(test)] mod a11y_tests; #[cfg(test)] @@ -78,6 +81,8 @@ mod cursors_tests; #[cfg(test)] mod dev_mode_tests; #[cfg(test)] +mod export_preview_tests; +#[cfg(test)] mod field_tests; #[cfg(test)] mod frame_label_tests; @@ -150,6 +155,8 @@ mod titlebar_tests; #[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] +mod web_clipboard_tests; +#[cfg(test)] mod windows_qa_tests; #[cfg(test)] mod windows_tests; @@ -213,6 +220,23 @@ pub trait Services { pub struct NoServices; impl Services for NoServices {} +/// Put `text` on the system clipboard through egui's output, which the browser shell writes while +/// it handles the copy event (the only time a page may write it). For shells without a clipboard +/// of their own. +pub fn egui_copy_text(ctx: &egui::Context, text: &str) { + ctx.copy_text(text.to_string()); +} + +/// Put a straight-RGBA image on the system clipboard through egui's output (see +/// [`egui_copy_text`]); a buffer that doesn't match its size is dropped. +pub fn egui_copy_png(ctx: &egui::Context, w: u32, h: u32, rgba: &[u8]) { + let (w, h) = (w as usize, h as usize); + if w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(rgba.len()) { + return; + } + ctx.copy_image(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba)); +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] pub enum RightTab { Design, @@ -888,11 +912,13 @@ impl FigApp { } let dark = match self.session.prefs.theme.as_str() { "dark" => true, - "system" => ctx.global_style().visuals.dark_mode, + // The active visuals were set by us last frame (possibly for a manual theme). + // Read the integration's OS theme instead; without one, keep the light fallback. + "system" => ctx.system_theme() == Some(egui::Theme::Dark), _ => false, }; self.ui.dark = dark; - // New files and pages take the theme's page colour (existing pages keep theirs). + // New files take the theme's page colour (existing pages keep theirs). self.session.set_ui_dark(dark); theme::apply_with(ctx, if dark { theme::Tokens::dark() } else { theme::Tokens::light() }, self.reduce_motion()); if self.worker.is_none() { diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index d1244ff4..6be28780 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -599,12 +599,15 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] (Icon::AlignBottom, tip(tr("Align bottom"), cmds[5])), ]; let w = col / 3.0; - if let Some(i) = widgets::segmented(ui, &items[..3], None, w) { + let picked = ui.add_enabled_ui(figcraft_engine::cmd::arrange::can_align(doc, sel), |ui| { + let h = widgets::segmented(ui, &items[..3], None, w); + let v = widgets::segmented(ui, &items[3..], None, w).map(|i| i + 3); + h.or(v) + }); + picked.response.on_disabled_hover_text(tr("A layer on its own aligns to its frame: select more layers, or one in a frame")); + if let Some(i) = picked.inner { app.run(cmds[i], json!({"ids": ids})); } - if let Some(i) = widgets::segmented(ui, &items[3..], None, w) { - app.run(cmds[i + 3], json!({"ids": ids})); - } if sel.len() > 2 { let more = icons::button(ui, Icon::DistributeH, 24.0, false, tr("Distribute and tidy")); menu_popup(&more).show(|ui| { diff --git a/crates/ui-egui/src/text_enter_tests.rs b/crates/ui-egui/src/text_enter_tests.rs index 2a0f3d22..5de0522d 100644 --- a/crates/ui-egui/src/text_enter_tests.rs +++ b/crates/ui-egui/src/text_enter_tests.rs @@ -75,5 +75,7 @@ fn a_new_file_takes_the_page_colour_of_the_theme_and_keeps_it() { u.app.run("view.themeDark", json!({})); u.frame(vec![]); u.app.run("page.add", json!({})); - assert_eq!(page_colour(&u), Color::DARK_PAGE, "a page added in the dark theme"); + assert_eq!(page_colour(&u), Color::DEFAULT_PAGE, "a page added in the dark theme follows its light page"); + u.app.run("file.new", json!({})); + assert_eq!(page_colour(&u), Color::DARK_PAGE, "a file made in the dark theme"); } diff --git a/crates/ui-egui/src/theme_startup_tests.rs b/crates/ui-egui/src/theme_startup_tests.rs new file mode 100644 index 00000000..6f2b13e2 --- /dev/null +++ b/crates/ui-egui/src/theme_startup_tests.rs @@ -0,0 +1,85 @@ +//! Startup and system-theme changes through the real UI frame, before any settling frames. + +use egui::{Context, RawInput, Theme}; +use figcraft_engine::{Prefs, Session}; +use serde_json::json; + +use crate::{FigApp, NoServices, theme::Tokens}; + +fn frame(ctx: &Context, app: &mut FigApp, system: Option) { + let mut output = ctx.run_ui(RawInput { system_theme: system, ..Default::default() }, |ui| app.ui(ui)); + output.textures_delta.clear(); +} + +#[test] +fn fresh_app_uses_system_dark_theme_on_its_first_frame() { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(NoServices)); + frame(&ctx, &mut app, Some(Theme::Dark)); + assert!(app.ui.dark, "a first run must use the OS dark theme before drawing"); + assert_eq!(Tokens::get(&ctx), Tokens::dark()); + assert_eq!(app.session.prefs.theme, "system", "following the OS must remain a preference, not a saved snapshot"); +} + +#[test] +fn first_run_and_preferences_without_a_theme_follow_system_light() { + for prefs in [Prefs::default(), serde_json::from_value(json!({"nudge_small": 2})).unwrap()] { + let ctx = Context::default(); + let mut session = Session::empty(); + session.prefs = prefs; + let mut app = FigApp::new(session, Box::new(NoServices)); + frame(&ctx, &mut app, Some(Theme::Light)); + assert_eq!(app.session.prefs.theme, "system"); + assert_eq!(Tokens::get(&ctx), Tokens::light()); + } +} + +#[test] +fn saved_manual_themes_survive_startup_and_os_changes() { + for (saved, expected) in [("light", Tokens::light()), ("dark", Tokens::dark())] { + let ctx = Context::default(); + let mut session = Session::empty(); + session.prefs = serde_json::from_value(json!({"theme": saved})).unwrap(); + let mut app = FigApp::new(session, Box::new(NoServices)); + for system in [Theme::Dark, Theme::Light, Theme::Dark] { + frame(&ctx, &mut app, Some(system)); + assert_eq!(Tokens::get(&ctx), expected); + assert_eq!(app.session.prefs.theme, saved); + } + } +} + +#[test] +fn returning_to_system_reads_the_os_not_the_previously_drawn_palette() { + for (manual, os) in [("view.themeLight", Theme::Dark), ("view.themeDark", Theme::Light)] { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(NoServices)); + app.session.execute(manual, &json!({})).unwrap(); + frame(&ctx, &mut app, Some(os)); + app.session.execute("view.themeSystem", &json!({})).unwrap(); + frame(&ctx, &mut app, Some(os)); + assert_eq!(app.ui.dark, os == Theme::Dark); + assert_eq!(Tokens::get(&ctx).dark, os == Theme::Dark); + } +} + +#[test] +fn system_theme_changes_apply_in_the_same_frame() { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(NoServices)); + app.session.execute("view.themeSystem", &json!({})).unwrap(); + for os in [Theme::Dark, Theme::Light, Theme::Dark] { + frame(&ctx, &mut app, Some(os)); + assert_eq!(Tokens::get(&ctx).dark, os == Theme::Dark); + assert_eq!(app.session.prefs.theme, "system"); + } +} + +#[test] +fn unavailable_system_theme_uses_the_existing_light_fallback() { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(NoServices)); + app.session.execute("view.themeSystem", &json!({})).unwrap(); + frame(&ctx, &mut app, None); + assert_eq!(Tokens::get(&ctx), Tokens::light()); +} diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 873ac0f0..56854f27 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -825,7 +825,11 @@ fn text_seg(ui: &mut Ui, salt: &str, items: &[(&str, &str)], active: Option Arc { + let mut job = egui::text::LayoutJob::default(); + let mut chars = label.chars(); + let first: String = chars.next().into_iter().collect(); + let rest: String = chars.map(small_capital_to_capital).collect::().to_uppercase(); + job.append(&first, 0.0, egui::TextFormat { font_id: regular(11.5), color: col, valign: egui::Align::BOTTOM, ..Default::default() }); + job.append(&rest, 0.0, egui::TextFormat { font_id: regular(8.5), color: col, valign: egui::Align::BOTTOM, ..Default::default() }); + ui.painter().layout_job(job) +} + +/// The capital a small-capital letter stands for (the ones a sample uses), else the letter. +fn small_capital_to_capital(c: char) -> char { + match c { + 'ɢ' => 'G', + 'ʜ' => 'H', + 'ɪ' => 'I', + 'ʟ' => 'L', + 'ɴ' => 'N', + 'ʀ' => 'R', + 'ʏ' => 'Y', + c => c, + } +} + /// One OpenType feature toggle; `on` None = mixed. Returns true when clicked. fn feature_row(ui: &mut Ui, tag: &str, label: &str, on: Option, supported: bool) -> bool { let t = Tokens::get(ui.ctx()); @@ -1136,6 +1167,29 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) mod tests { use super::*; + #[test] + fn small_caps_sample_draws_only_letters_the_ui_font_has() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, false, 0); + let mut shown = None; + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 300.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + shown = Some(small_caps_sample(ui, "Aɢ", Color32::WHITE)); + }); + out.textures_delta.clear(); + let g = shown.expect("drawn"); + assert_eq!(g.job.text, "AG"); + let [a, rest] = g.job.sections.as_slice() else { panic!("two sections: {:?}", g.job.sections) }; + assert!(rest.format.font_id.size < a.format.font_id.size, "the other letters are smaller capitals"); + ctx.fonts_mut(|f| { + for sec in &g.job.sections { + assert!(f.has_glyphs(&sec.format.font_id, &g.job.text), "UI font draws {:?}", g.job.text); + } + }); + assert_eq!(small_capital_to_capital('ʀ'), 'R'); + assert_eq!(small_capital_to_capital('x'), 'x'); + } + #[test] fn parses_line_height_and_letter_spacing() { assert_eq!(parse_line_height("Auto", None), Some(json!("auto"))); diff --git a/crates/ui-egui/src/web_clipboard_tests.rs b/crates/ui-egui/src/web_clipboard_tests.rs new file mode 100644 index 00000000..a7787120 --- /dev/null +++ b/crates/ui-egui/src/web_clipboard_tests.rs @@ -0,0 +1,141 @@ +//! Copy and paste as the browser does them: Cmd+C arrives as a copy event and the copy reaches the +//! system clipboard only through egui's output; Cmd+V arrives as the key and a paste event holding +//! whatever the system clipboard has. A copy that never reached the clipboard pasted the text that +//! was there before as a text layer instead of the copied layers. + +use egui::{Event, Key, Modifiers, OutputCommand, Pos2, RawInput, Rect, vec2}; +use figcraft_doc::NodeKind; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, Services, egui_copy_png, egui_copy_text}; + +/// The browser shell's clipboard: writes go out as egui output commands. +struct Browser(egui::Context); + +impl Services for Browser { + fn clipboard_set_text(&mut self, s: &str) { + egui_copy_text(&self.0, s); + } + fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { + egui_copy_png(&self.0, w, h, rgba); + } +} + +struct Page { + ctx: egui::Context, + app: FigApp, + time: f64, + /// The system clipboard's text. + clipboard: String, +} + +impl Page { + fn new() -> Self { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + s.execute("select.set", &json!({"ids": [r["id"]]})).unwrap(); + let ctx = egui::Context::default(); + let app = FigApp::new(s, Box::new(Browser(ctx.clone()))); + let mut p = Self { ctx, app, time: 0.0, clipboard: "text copied in another app".into() }; + for _ in 0..3 { + p.frame(vec![]); + } + p + } + + /// One frame; copies in its output land on the clipboard, as eframe writes them. + fn frame(&mut self, events: Vec) -> Vec { + self.frame_then(events, |_| {}) + } + + /// One frame that runs `then` after the app's own UI (a menu item clicked during the frame). + fn frame_then(&mut self, events: Vec, then: impl FnOnce(&mut FigApp)) -> Vec { + self.time += 1.0 / 60.0; + 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 then = Some(then); + let mut out = self.ctx.run_ui(input, |ui| { + app.ui(ui); + if let Some(f) = then.take() { + f(app); + } + }); + out.textures_delta.clear(); + for c in &out.platform_output.commands { + if let OutputCommand::CopyText(t) = c { + self.clipboard = t.clone(); + } + } + out.platform_output.commands + } + + fn key(key: Key, pressed: bool) -> Event { + Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: Modifiers::MAC_CMD | Modifiers::COMMAND } + } + + fn copy(&mut self) { + self.frame(vec![Self::key(Key::C, true), Event::Copy]); + self.frame(vec![Self::key(Key::C, false)]); + } + + fn paste(&mut self) { + let text = self.clipboard.clone(); + self.frame(vec![Self::key(Key::V, true), Event::Paste(text)]); + self.frame(vec![Self::key(Key::V, false)]); + } + + fn count(&self, kind: fn(&NodeKind) -> bool) -> usize { + self.app.session.doc().unwrap().nodes().filter(|n| kind(&n.kind)).count() + } +} + +fn rect(k: &NodeKind) -> bool { + matches!(k, NodeKind::Rectangle) +} + +fn text(k: &NodeKind) -> bool { + matches!(k, NodeKind::Text(_)) +} + +#[test] +fn cmd_c_then_cmd_v_pastes_the_copied_layers() { + let mut p = Page::new(); + p.copy(); + assert_ne!(p.clipboard, "text copied in another app", "the copy reached the system clipboard"); + p.paste(); + assert_eq!(p.count(rect), 2, "the rectangle was pasted"); + assert_eq!(p.count(text), 0, "no text layer from the clipboard's old text"); + p.paste(); + assert_eq!(p.count(rect), 3, "and again"); +} + +#[test] +fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() { + let mut p = Page::new(); + p.copy(); + p.clipboard = "hello from another app".into(); + p.paste(); + assert_eq!(p.count(rect), 1); + assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer"); +} + +#[test] +fn copy_as_png_puts_an_image_on_the_clipboard() { + let mut p = Page::new(); + let out = p.frame_then(vec![], |app| crate::menus::invoke(app, "edit.copyAsPng")); + let img = out.iter().find_map(|c| if let OutputCommand::CopyImage(i) = c { Some(i.clone()) } else { None }); + let img = img.expect("an image was copied"); + assert!(img.size[0] > 0 && img.size[1] > 0); +} + +#[test] +fn a_mismatched_image_buffer_is_dropped() { + let ctx = egui::Context::default(); + for (w, h, rgba) in [(4, 4, &[0u8; 10][..]), (u32::MAX, u32::MAX, &[][..])] { + let mut out = ctx.run_ui(RawInput::default(), |ui| egui_copy_png(ui.ctx(), w, h, rgba)); + out.textures_delta.clear(); + assert!(out.platform_output.commands.is_empty(), "{w}x{h}"); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 508e6fc4..db696a1e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -625,6 +625,21 @@ pub fn elide_font(ui: &Ui, s: &str, max_w: f32, font: egui::FontId) -> String { .unwrap_or_else(|| "…".into()) } +/// [`elide_font`] cut at its start, keeping its end: a path keeps its file and the folders +/// nearest it. +pub fn elide_start_font(ui: &Ui, s: &str, max_w: f32, font: egui::FontId) -> String { + let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x <= max_w; + if fits(s) { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + (1..chars.len()) + .rev() + .map(|n| format!("…{}", chars[chars.len() - n..].iter().collect::().trim_start())) + .find(|c| fits(c)) + .unwrap_or_else(|| "…".into()) +} + /// A row of icon buttons; returns the index clicked. pub fn segmented(ui: &mut Ui, items: &[(Icon, &str)], active: Option, cell: f32) -> Option { let names: Vec<&str> = items.iter().map(|(_, tip)| *tip).collect(); diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 7888241d..874988a3 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -726,8 +726,10 @@ resuming with its offline edits, and leaving the session going back to snapshot - **What is kept.** The published library a library file holds (manifest and snapshot with per-asset versions) travels as a small `.figcraft` archive holding only it. The server reads it in full with the limits a library file is read with, requires the file's library id once one is - kept and a newer version, then writes it beside the file (`library.bin`, atomically) and records - id, name and version in the file's description. Publishing from the app first fetches the + kept and a newer version, then writes it beside the file in a file of its own + (`library-.bin`, atomically) and only then records id, name and version in the file's + description, so a failed write or a crash in between leaves the previous version served whole; + the previous version's file is deleted after. Publishing from the app first fetches the server's version and publishes the next one from it, so per-asset versions continue whoever published last; a race is refused and the next publish follows. - **Using it.** A file enables the library with the link in place of a library file's path diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 8995db25..a990d75b 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-11 · **Change:** minor (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-14 · **Change:** minor (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 @@ -43,13 +43,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 | | Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here | | FreeBSD | none | tarball | | -| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser | +| 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) | | 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-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. | | 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 through the desktop app and MCP; fixed: unsaved changes lost when the app was killed or crashed (recovery copies every few seconds, reopened at the next start), Ctrl+arrows/Backspace/Delete acting on the whole line in text (now by word; Ctrl+Home/End to the text's ends), scrubbed fields snapping back on release, typing on a field Tab reached, Escape not closing dialogs, skewed or vanishing linear gradients, hints cut off at the panel edge, the window-close question in English in the Spanish interface. Percentages and hours unchanged | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | | 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 99f0f1a7..ecf1bc71 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -30,12 +30,13 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, English, Deutsch, Español, Español (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), 简体中文), applied at once without a - restart; System takes the first of the OS's preferred languages that has a catalog (Windows' - language list, macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional tag - falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the other - Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans, while - Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the next OS - language, never Simplified) or the browser's language on the web. + restart; System takes the first of the OS's preferred languages that has a catalog (on Windows + only the first preferred language counts, since that list also holds the languages added for a + keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional + tag falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the + other Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans, + while Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the + next OS language, never Simplified) or the browser's language on the web. `pref.language {language}` sets it from the CLI, MCP and the control channel. - **Regional overlays:** a regional catalog whose language has a catalog lists only what differs (`es-419.toml` over `es.toml`); its UI strings, command labels and menu wordings read the @@ -107,6 +108,7 @@ Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog | | 2026-10-12 | minor | Drafts catch up again, and the counts with them: French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese translate the 71 UI strings and 5 command labels they lacked since sharing with a join code and open hosting (the Share popover, join codes, owner tokens, reports, the .fig export notes), and with Spanish the 26 strings and 3 command labels of your online files (Home ▸ Online, its menu and dialogs, the marks on tabs); machine-drafted in each file's conventions, still drafts. The Latin American overlay adds computadora and agrega where the new Spanish says ordenador and añade (98 entries). 2,076 entries per catalog (1,503 UI strings, 544 command labels, 29 menu wordings) | | 2026-10-12 | minor | Draft catalogs catch up: French, German, Brazilian Portuguese, Japanese and Korean translate the 66 UI strings and 17 command labels added since they were drafted (cursor chat and spotlight, the plugin directory, Rename layers, presentation and snapping keys, list levels, the rotation origin, placing with Enter…), Simplified Chinese the 46 and 12 it lacked; machine-drafted from the English in each file's conventions, still drafts (not in `COMPLETE`). The Latin American overlay adds Control for a gamepad controller and presiona in the plugin directory hint (90 entries, with Enter for the keycap). String counts re-measured: 1,417 UI keys, 536 command labels and 29 menu wordings (1,982 entries), every draft at 100%. Localization grade unchanged | | 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h | diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c..054af8db 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -301,9 +301,9 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: given, are or are in; default the selection) out of the library the file publishes, or let them back in (`on: false`; without `on` it toggles). The sign of it is the name: hiding puts a `.` before it, showing takes the leading `.` and `_` away. Returns `{ids, hidden}`; one undo step. -- New files and pages: `file.new` and `page.add` give a page the colour `#F5F5F5`. Only the app - with its dark theme showing makes them `#1E1E1E`; headless sessions (the CLI, this server) have - no theme. Text made with `text.create` or `node.create` is black whatever the page here; in the +- New files and pages: `file.new` gives its page the colour `#F5F5F5`. Only the app with its dark + theme showing makes it `#1E1E1E`; headless sessions (the CLI, this server) have no theme. + `page.add` gives the new page the current page's colour, wherever it runs. Text made with `text.create` or `node.create` is black whatever the page here; in the app (the control channel, `--attach`) it is white on a dark page unless `props` name a `fill`, as the Text tool (`pointer` with `tool.text`) makes it everywhere. - `transform.rotationOrigin {on?}` (`Alt+R`): show or hide the selection's rotation origin handle @@ -1406,6 +1406,12 @@ turned off 0°. With `pointer`, `alt: true` hovers measure (add `cmd` for nested layers, `shift` for padding) and `alt` drags show live distances to the neighbours; `inspect_ui` reports them under `tool.overlay` (`guides`, and `measure_box` for the box measured to). +- **Duplicating at a spacing**: a copy Alt-dragged off a layer with `pointer` snaps to that layer + too (its edges, centres and equal spacing). `edit.duplicate {ids?, dx?, dy?}` without `dx`/`dy` + repeats the last duplicate's page offset while its copies are what it duplicates (and still the + selection), measured when it runs: Alt-drag or duplicate and move a copy, then each + `edit.duplicate` steps out the next one as far again. Copies placed by an auto layout set no + step; explicit `dx`/`dy` always win. - **Scale tool** (`tool.scale`, K): dragging a selection handle with `pointer` scales everything by one factor (sizes and positions whatever the constraints, strokes, effects, corner radii, auto layout spacing, min/max sizes, layout grids, guides, tiled images, text), one undo step per drag, diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e2780d6..12f2a5fb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/docs/server.md b/docs/server.md index 88d99928..f2e4820c 100644 --- a/docs/server.md +++ b/docs/server.md @@ -320,7 +320,8 @@ behaves as before (blocked addresses, report rates and the limits on a link with a join code its stretched hash and the wrapped token /files//snapshot.bin its state at the last compaction (with a checksum) /files//log.bin what changed since, one checksummed record per change -/files//library.bin its team library, when one was published (a .figcraft archive) +/files//library-.bin its team library, when one was published (a .figcraft + archive; library.bin when it was published by an older server) /files//usage.json which files use that library's components, as their apps report it /plugins/index.json the plugin directory: every version kept (manifest fields, size, SHA-256) /plugins/.bin a plugin bundle, named by the SHA-256 of its bytes diff --git a/docs/translating.md b/docs/translating.md index b7c26a40..cd587b25 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -4,9 +4,11 @@ FigCraft's interface strings live in one TOML catalog per language in [`crates/ui-egui/locales/`](../crates/ui-egui/locales). The build bakes every catalog into the binary (desktop and web); nothing is read at run time. Preferences ▸ Language picks the language and the interface switches at once. System takes the first of the OS's preferred languages that has -a catalog (the browser's language on the web): a regional tag reads its language's catalog -(`fr-CA` → `fr`), a language with only a regional catalog reads that one (`pt-PT` → `pt-BR`), and -Latin American and US Spanish (`es-MX`, `es-AR`, `es-CO`, `es-US`, `es-419`…) read `es-419`. +a catalog (the browser's language on the web; on Windows only the first preferred language counts, +since that list also holds the languages added for a keyboard, and a first language without a +catalog reads English): a regional tag reads its language's catalog (`fr-CA` → `fr`), a language +with only a regional catalog reads that one (`pt-PT` → `pt-BR`), and Latin American and US Spanish +(`es-MX`, `es-AR`, `es-CO`, `es-US`, `es-419`…) read `es-419`. Chinese (`zh`, `zh-CN`, `zh-SG`, `zh-Hans`) reads `zh-Hans`; Traditional Chinese (`zh-Hant`, `zh-TW`, `zh-HK`, `zh-MO`) has no catalog and reads English (or the next OS language), not Simplified. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6de538e7..0cdc94e3 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-14 · **Change:** minor (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-14 · **Change:** minor (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: @@ -55,7 +55,7 @@ interaction feel not yet compared. | Flip, rotate 90° | done | | | Rotation origin (Alt+R) | done | a handle at the selection's centre to drag (snaps to the box's corners, edge middles and centre), double-click puts it back, Escape or Alt+R hides it; the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about it; it follows the layers and lasts as long as the selection, not saved (the documentation doesn't say; pinned) | | Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. No crosshair to move with the arrows first | -| Alt-drag duplicate | done | | +| Alt-drag duplicate | done | the copy snaps to the layer it came from too (edges, centres, equal spacing); Duplicate (Cmd+D) then repeats the copy's offset, also after a Duplicate whose copy was moved, as long as the copies stay selected (the documentation doesn't say; pinned from community answers) | | Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) | | Scale tool (K): handles about an anchor, live factor, panel | done | | | Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children | @@ -164,6 +164,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Type settings: the Small caps button draws its sample as a capital and a smaller capital, no longer as a box where the UI font lacks the small-capital letter (egui's default fonts) | | 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu | | 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) | | 2026-10-13 | minor | Home screen: a Templates tab (eight starter templates as cards with thumbnails, search and a kind filter; click or Enter opens a new document) replaces the Community placeholder, the Drafts placeholder is removed, Recents offers four templates while there are no recent files and shows a Getting started card (closable; Help ▸ Getting started brings it back), File ▸ New from template…, and a first run opens on the home screen |