diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index feb661324..dcd0192f0 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -43,8 +43,9 @@ jobs: mem: 12288 # Nothing to bring back; copying the target dir out of the VM fails on large builds. copyback: false + # dejavu: the text tests need one real font, as the Ubuntu runner has. prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 + pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 dejavu curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal run: | set -e diff --git a/Cargo.lock b/Cargo.lock index 5e9c6f684..aead39d1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,6 +1418,7 @@ dependencies = [ "figcraft-format", "figcraft-text", "figcraft-ui-egui", + "fmv-macos-events", "image", "log", "rfd", @@ -1680,6 +1681,7 @@ dependencies = [ "figcraft-geom", "harfrust 0.13.3", "log", + "png", "skrifa 0.47.0", "unicode-bidi", "unicode-segmentation", @@ -1707,6 +1709,7 @@ dependencies = [ "serde_json", "toml", "wat", + "web-time", ] [[package]] @@ -1718,6 +1721,7 @@ dependencies = [ "figcraft-collab-net", "figcraft-engine", "figcraft-ui-egui", + "js-sys", "log", "wasm-bindgen", "wasm-bindgen-futures", @@ -1750,6 +1754,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fmv-macos-events" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" +dependencies = [ + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + [[package]] name = "fnv" version = "1.0.7" diff --git a/README.md b/README.md index 9b67078be..0910bf9e7 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English and Spanish (a draft waiting for a native speaker's review), picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29b..ace7702ea 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-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index 390e64143..9dd522031 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -78,6 +78,27 @@ fn run_export_info_convert() { std::fs::remove_dir_all(&d).ok(); } +#[test] +fn run_takes_the_documented_gradient_shorthand() { + // #252: only MCP used to accept it; `run` said "`fills` must be paints or colours". + let d = tmp("gradient"); + let script = d.join("s.json"); + std::fs::write( + &script, + r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}}, + {"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##, + ) + .unwrap(); + let file = d.join("g.figcraft"); + let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + let json = d.join("g.json"); + assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success()); + let doc = std::fs::read_to_string(&json).unwrap(); + assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}"); + std::fs::remove_dir_all(&d).ok(); +} + #[test] fn mcp_over_stdio() { let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); diff --git a/apps/figcraft-server/src/hardening_tests.rs b/apps/figcraft-server/src/hardening_tests.rs index 6a74e6174..086539cae 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 e5e56b8fb..4886bc888 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; } @@ -1438,6 +1489,8 @@ impl Backend for Store { Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), Admin::Owned { files } => self.owned(&files).into(), + // The hub removes people itself (it knows who is connected); it never asks the store. + Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), Admin::Remove { file } => { if !self.files.contains_key(&file) { return failed("this file isn't on the server (it may have been removed)".into()); diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index fda51d4f5..f380568c8 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/Cargo.toml b/apps/figcraft-web/Cargo.toml index 93f9eb41c..2f1a8190f 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -24,5 +24,6 @@ log = { workspace = true } # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" +js-sys = "0.3" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 636890385..8cadb0138 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,35 @@ 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. Open dialogs return nothing for now (the trait's defaults); files are saved +/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste +/// events with their text (a page can't read the clipboard when it likes). struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option, + ctx: egui::Context, } impl Services for WebServices { + fn downloads(&self) -> bool { + true + } + /// A Blob behind a temporary `` link, clicked and removed. + fn download(&mut self, name: &str, bytes: &[u8]) -> bool { + match download(name, bytes) { + Ok(()) => true, + Err(e) => { + log::error!("couldn't download {name}: {e:?}"); + false + } + } + } + fn clipboard_set_text(&mut self, s: &str) { + figcraft_ui_egui::egui_copy_text(&self.ctx, s); + } + 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") { @@ -108,3 +129,32 @@ impl Services for WebServices { held } } + +fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let body = document.body().ok_or("no body")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{ + let o = web_sys::BlobPropertyBag::new(); + o.set_type("application/octet-stream"); + o + })?; + let url = web_sys::Url::create_object_url_with_blob(&blob)?; + let a: web_sys::HtmlAnchorElement = document.create_element("a")?.dyn_into()?; + a.set_href(&url); + a.set_download(name); + body.append_child(&a)?; + a.click(); + a.remove(); + // Revoked later, not at once: some browsers cancel a download whose URL goes before it starts. + let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); + // Without the timer the URL just stays until the page closes. + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_DOWNLOAD_MS); + Ok(()) +} + +/// How long a download's object URL is kept (milliseconds). +const REVOKE_DOWNLOAD_MS: i32 = 40_000; diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index b64b68d88..bafb6da14 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -31,6 +31,13 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col [target.'cfg(windows)'.dependencies] windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] } +# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a +# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft, +# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs +# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping. +[target.'cfg(target_os = "macos")'.dependencies] +fmv-macos-events = "=0.1.0" + [dev-dependencies] wat = { workspace = true } diff --git a/apps/figcraft/src/apple_events.rs b/apps/figcraft/src/apple_events.rs new file mode 100644 index 000000000..6b7920944 --- /dev/null +++ b/apps/figcraft/src/apple_events.rs @@ -0,0 +1,57 @@ +//! macOS open-documents and quit Apple events (#289). +//! +//! Finder double-clicks, Open With, drops on the Dock icon and `open -a FigCraft file.figcraft` +//! don't pass paths on the command line: LaunchServices sends the running (or just-launched) app a +//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the +//! `NSApplicationDelegate`, so AppKit answered "FigCraft cannot open files in the FigCraft Document +//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this +//! workspace forbids. The `fmv-macos-events` crate (also used by GridCraft, EffectCraft and +//! PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler registered before Finder's +//! launch event that leaves winit's delegate alone, behind a safe main-thread API. + +use std::path::PathBuf; + +use figcraft_ui_egui::FigApp; +use fmv_macos_events::{Event, Inbox, Registration}; +use serde_json::json; + +/// Keeps the Apple-event handlers registered; hold it until the event loop returns. +pub struct AppleEvents { + _registration: Registration, + inbox: Inbox, +} + +impl AppleEvents { + /// Register the handlers. Call on the main thread before the event loop starts, so the event + /// that launched the app (a Finder double-click) is caught too. + pub fn install() -> Self { + let (registration, inbox) = Registration::install(); + Self { _registration: registration, inbox } + } + + /// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`. + pub fn connect(&self, ctx: &egui::Context) -> Inbox { + let ctx = ctx.clone(); + self.inbox.set_wake(move || ctx.request_repaint()); + self.inbox.clone() + } +} + +/// Open the documents and act on the quit requests that arrived since the last frame. +pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) { + for e in inbox.drain() { + match e { + Event::Open(paths) => open_documents(fig, paths), + Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } +} + +/// Open `paths` as File › Open does; a file that fails shows its error and the others still open. +pub fn open_documents(fig: &mut FigApp, paths: Vec) { + for p in paths { + if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() { + fig.ui.home = false; + } + } +} diff --git a/apps/figcraft/src/apple_events_tests.rs b/apps/figcraft/src/apple_events_tests.rs new file mode 100644 index 000000000..173f5385c --- /dev/null +++ b/apps/figcraft/src/apple_events_tests.rs @@ -0,0 +1,30 @@ +//! Documents macOS hands over in an open-documents Apple event (a Finder double-click, Open With, +//! a drop on the Dock icon) open as File › Open opens them (#289). + +use std::path::PathBuf; + +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, NoServices}; +use serde_json::json; + +use crate::apple_events::open_documents; + +#[test] +fn documents_from_finder_open_and_leave_the_home_screen() { + let dir = std::env::temp_dir().join(format!("figcraft-apple-events-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("BLA-001.figcraft"); + let mut s = Session::new(); + s.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap(); + + let mut app = FigApp::new(Session::empty(), Box::new(NoServices)); + app.ui.home = true; + // A file that can't be read is reported and doesn't keep the others from opening. + open_documents(&mut app, vec![dir.join("Missing.figcraft"), file.clone()]); + let paths: Vec<_> = app.session.docs.iter().map(|d| d.path.clone().map(PathBuf::from)).collect(); + assert_eq!(paths, [Some(file)]); + assert!(!app.ui.home, "the opened document shows"); + assert!(app.ui.last_error.as_deref().is_some_and(|e| e.contains("Missing.figcraft")), "{:?}", app.ui.last_error); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index 5cfe12924..995240f50 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -107,7 +107,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s }, Err(_) => { cancelled.store(true, std::sync::atomic::Ordering::Relaxed); - json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"}) + json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"}) } } } diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 0332f7d83..fdcc66f9e 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -9,13 +9,18 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +#[cfg(target_os = "macos")] +mod apple_events; mod args; mod control_server; mod gamepad; mod graphics; mod recovery; mod services; +mod startup; +#[cfg(all(test, target_os = "macos"))] +mod apple_events_tests; #[cfg(test)] mod args_tests; #[cfg(test)] @@ -26,6 +31,8 @@ mod recovery_online_tests; mod recovery_tests; #[cfg(test)] mod services_tests; +#[cfg(test)] +mod startup_tests; use std::sync::{Arc, Mutex}; @@ -43,6 +50,9 @@ struct App { title: String, /// Shared with the services, which write the copies after a panic. recovery: recovery::Shared, + /// Documents and quit requests from Finder, the Dock and Open With. + #[cfg(target_os = "macos")] + apple_events: fmv_macos_events::Inbox, } impl eframe::App for App { @@ -55,6 +65,9 @@ impl eframe::App for App { } let title = with_app(|fig| { control_server::drain_queue(&self.queue, &mut fig.control_queue); + figcraft_ui_egui::control::drain_hidden(fig, ctx); + #[cfg(target_os = "macos")] + apple_events::poll(&self.apple_events, fig, ctx); // Closing the window asks about unsaved changes (and keeps the app running while other // windows are open). figcraft_ui_egui::windows::on_close_request(fig, ctx); @@ -81,9 +94,9 @@ impl eframe::App for App { } } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); - with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui))); + services::parented(frame, || with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui)))); } fn on_exit(&mut self) { @@ -157,6 +170,9 @@ fn main() -> eframe::Result { if session.docs.is_empty() && !first_run { let _ = session.execute("file.new", &json!({})); } + // The interface language from the first frame on, and for the message when the window can't + // open (no frame then). + figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref()); let mut fig = FigApp::new(session, Box::new(svc)); fig.ui.home = false; fig.note_recovered(recovered.copies); @@ -168,8 +184,11 @@ fn main() -> eframe::Result { control_server::start(p, queue.clone(), ctx_slot.clone()); } let icon = services::window_icon(); + // The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name + // and StartupWMClass (FreeBSD's package matches the binary's name instead). let mut viewport = egui::ViewportBuilder::default() .with_title("FigCraft") + .with_app_id("ai.storyteller.figcraft") .with_inner_size([1440.0, 900.0]) .with_min_inner_size([800.0, 500.0]) .with_drag_and_drop(true); @@ -188,5 +207,29 @@ fn main() -> eframe::Result { let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() }; graphics::configure(&mut options.wgpu_options); figcraft_ui_egui::windows::install(fig); - eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery })))) + // Registered before the event loop starts, so it catches the Finder event that launched the app + // as well as later ones. Lives until the event loop returns; the app creator only borrows it. + #[cfg(target_os = "macos")] + let apple_events = apple_events::AppleEvents::install(); + #[cfg(target_os = "macos")] + let apple_events = &apple_events; + let result = eframe::run_native( + "FigCraft", + options, + Box::new(move |_cc| { + Ok(Box::new(App { + queue, + ctx_slot, + title: String::new(), + recovery, + #[cfg(target_os = "macos")] + apple_events: apple_events.connect(&_cc.egui_ctx), + })) + }), + ); + // Say why instead of quitting silently: release builds on Windows have no console. + if let Err(e) = &result { + startup::failed(e, port.is_some()); + } + result } diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 93335394f..b2614598f 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -4,7 +4,10 @@ //! in the config folder, in the background, at most every [`INTERVAL`] seconds, and the copy goes //! once the document is saved or closed. The app holds a lock on its folder. At startup, the folders //! whose lock is free belong to runs that ended without closing: their copies open again as -//! unsaved documents (`file.recover`) and the folder goes. Closing the app normally removes its own. +//! unsaved documents (`file.recover`). Each such copy **stays until this run has its own copy of +//! the document** (or it is saved or closed): until then it is the only copy on disk, and a run +//! that can't keep copies of its own leaves it for the next start. Then it goes, and its folder +//! with its last copy. Closing the app normally removes its own. //! //! A document that is one of your online files has a note beside its copy (`.online`, written //! after the copy it describes): the server, the file's id, and who the app was in the file with @@ -22,7 +25,8 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use std::thread::JoinHandle; -use figcraft_engine::Session; +use figcraft_engine::{DocState, Session}; +use figcraft_format::{Branch, Version}; use serde_json::json; /// Seconds between copies of a document that keeps changing. @@ -41,20 +45,61 @@ pub struct Recovery { dir: PathBuf, /// Held while the app runs: a free lock means the run that wrote the folder is gone. _lock: File, - /// The revision each document was last copied at, and the write in flight. - written: HashMap>)>, + /// What was copied of each document, and the write in flight. + written: HashMap, /// When (UI clock) the next copies are due. next: f64, /// Copies of online files from earlier runs, kept until their outcome is known. waiting: Vec, } -/// A copy of one of your online files whose file is being joined again as session `session`. +/// The state of the copy of one document: only a write that succeeded counts as a copy. +#[derive(Default)] +struct Copy { + /// The [`stamp`] whose copy was written. + done: Option>, + /// The write in flight: the stamp it copies, and whether it succeeded once it ends. + flight: Option<(Vec, JoinHandle)>, +} + +impl Copy { + /// Take in the result of the write in flight once it ended; a failed one leaves its revision uncopied. + fn reap(&mut self) { + if self.flight.as_ref().is_some_and(|f| f.1.is_finished()) { + self.join(); + } + } + + /// Wait for the write in flight and take in its result. + fn join(&mut self) { + if let Some((stamp, h)) = self.flight.take() + && h.join().unwrap_or(false) + { + self.done = Some(stamp); + } + } + + /// Whether `stamp` has neither been copied nor is being copied. + fn pending(&self, stamp: &[u64]) -> bool { + self.done.as_deref() != Some(stamp) && self.flight.as_ref().is_none_or(|f| f.0 != stamp) + } +} + +/// A copy from an earlier run, kept until what it became is safe. pub struct Waiting { - session: u64, + until: Until, copy: PathBuf, - /// The lock of the folder it is in: no other start takes the copy meanwhile. - _lock: Arc, + /// The lock of the folder it is in: no other start takes the copy meanwhile (none for a copy + /// left directly in the root by older versions). + _lock: Option>, +} + +/// What a kept copy waits for. +enum Until { + /// One of your online files, joined again as this session: its outcome. + Session(u64), + /// Opened as document `uid`: this run's own copy of it (or its save or close). + Copied(u64), } /// What [`recover_all`] brought back. @@ -62,21 +107,26 @@ pub struct Waiting { pub struct Recovered { /// Documents opened from their copies. pub copies: usize, - /// Copies of online files waiting for their file ([`Recovery::adopt`]). + /// The copies kept until what they became is safe ([`Recovery::adopt`]). pub waiting: Vec, } impl Recovery { /// Take this run's folder under `root` (`None` when it can't be created or locked: no copies). + /// A crashed run with the same process id may still hold its folder (its copies kept): then + /// another name is taken. pub fn start(root: &Path) -> Option { - let dir = root.join(std::process::id().to_string()); - std::fs::create_dir_all(&dir).ok()?; - let lock = File::create(dir.join(LOCK)).ok()?; - lock.try_lock().ok()?; - Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() }) + let pid = std::process::id(); + (0..10).find_map(|n| { + let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") }); + std::fs::create_dir_all(&dir).ok()?; + let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?; + lock.try_lock().ok()?; + Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() }) + }) } - /// Keep earlier runs' copies of online files until their outcome is known. + /// Keep earlier runs' copies until what they became is safe. pub fn adopt(&mut self, waiting: Vec) { self.waiting.extend(waiting); } @@ -85,38 +135,47 @@ impl Recovery { /// were saved or closed. Returns how long until it needs to run again (`None`: nothing pending, /// an idle window can sleep). pub fn tick(&mut self, s: &Session, now: f64) -> Option { + self.written.values_mut().for_each(Copy::reap); self.settle(s); - // A join on its way settles within its timeouts: look again soon. - let settling = (!self.waiting.is_empty()).then_some(0.5); - let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision)); + // A join on its way settles within its timeouts: look again soon. A copy waiting for this + // run's own is looked at again with the next copies. + let joining = self.waiting.iter().any(|w| matches!(w.until, Until::Session(_))); + let settling = if joining { Some(0.5) } else { (!self.waiting.is_empty()).then_some(INTERVAL) }; if now < self.next { - return pending.then_some(self.next - now).or(settling); + return self.pending(s).then_some(self.next - now).or(settling); } self.write(s, false); self.next = now + INTERVAL; - settling + // A copy that failed is tried again at the next turn, without another edit. + settling.or(self.pending(s).then_some(INTERVAL)) } - /// Remove earlier runs' copies whose outcome is known, once what they became is copied here. - fn settle(&mut self, s: &Session) { - if self.waiting.iter().all(|w| s.live.recovering(w.session)) { - return; + /// Whether a document with unsaved changes has a revision that is neither copied nor being copied. + fn pending(&self, s: &Session) -> bool { + s.docs.iter().any(|d| needs_copy(s, d) && self.written.get(&d.uid).is_none_or(|w| w.pending(&stamp(s, d)))) + } + + /// Remove earlier runs' copies once what they became is safe: copied here (the write + /// succeeded), saved or closed. An online file's copy goes once its outcome is known and every + /// document is safe, since which of them it became isn't known. + pub fn settle(&mut self, s: &Session) { + let joined = |w: &Waiting| matches!(w.until, Until::Session(id) if !s.live.recovering(id)); + if self.waiting.iter().any(joined) { + self.write(s, true); } - self.write(s, true); - let (done, waiting): (Vec, Vec) = - std::mem::take(&mut self.waiting).into_iter().partition(|w| !s.live.recovering(w.session)); + let all_safe = s.docs.iter().all(|d| self.safe(s, d)); + let (done, waiting): (Vec, Vec) = std::mem::take(&mut self.waiting).into_iter().partition(|w| match w.until { + Until::Session(_) => joined(w) && all_safe, + Until::Copied(uid) => s.docs.iter().find(|d| d.uid == uid).is_none_or(|d| self.safe(s, d)), + }); self.waiting = waiting; - for w in done { - let _ = std::fs::remove_file(&w.copy); - let _ = std::fs::remove_file(w.copy.with_extension(ONLINE)); - // The folder goes with its last copy (while another waits there its lock is held). - let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| Arc::strong_count(&w._lock) == 1); - drop(w); - if let Some(dir) = dir { - let _ = std::fs::remove_file(dir.join(LOCK)); - let _ = std::fs::remove_dir(dir); - } - } + done.into_iter().for_each(remove_kept); + } + + /// Whether document `d`'s work is on disk: saved (or nothing to keep), or copied here (a write + /// that succeeded) with no write in flight. + fn safe(&self, s: &Session, d: &DocState) -> bool { + !needs_copy(s, d) || self.written.get(&d.uid).is_some_and(|w| w.done.is_some() && w.flight.is_none()) } /// Copy every document with unsaved changes; `wait`: before returning (after a panic). @@ -124,47 +183,60 @@ impl Recovery { for d in &s.docs { let path = self.dir.join(format!("{}.figcraft", d.uid)); let note = path.with_extension(ONLINE); - if !needs_copy(d) { - if self.written.remove(&d.uid).is_some() { + if !needs_copy(s, d) { + if let Some(mut w) = self.written.remove(&d.uid) { + // A write still running would bring the copy back after it was removed. + w.join(); let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(¬e); } continue; } - let entry = self.written.entry(d.uid).or_insert((0, None)); - let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished()); - if entry.0 == d.revision || busy { + let entry = self.written.entry(d.uid).or_default(); + // An emergency save must copy the newest revision: let the write in flight finish first. + if wait { + entry.join(); + } else { + entry.reap(); + } + // A write still in flight (for any revision) is waited for; a failed one is tried again. + let stamp = stamp(s, d); + if entry.done.as_ref() == Some(&stamp) || entry.flight.is_some() { continue; } let doc = d.doc.clone(); + let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid)); // Which online file it is and what of the server's this very copy has (a line each: // the server, the file's id, then the session, client id and last change), while it // is one. Written after the copy, never before: a note must not promise more of the // server's changes than its copy holds. let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq)); let job = move || { - if !write_atomic(&path, &doc) { - return; + if !write_atomic(&path, &doc, &versions, &branches) { + return false; } let _ = match online { Some(text) => std::fs::write(¬e, text), None => std::fs::remove_file(¬e), }; + true }; + // The stamp counts as copied only once its write succeeded. if wait { - job(); - entry.1 = None; + if job() { + entry.done = Some(stamp); + } } else { - entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok(); + entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (stamp, h)); } - entry.0 = d.revision; } // Closed documents. let open: Vec = s.docs.iter().map(|d| d.uid).collect(); let dir = &self.dir; - self.written.retain(|uid, _| { + self.written.retain(|uid, w| { let keep = open.contains(uid); if !keep { + w.join(); let _ = std::fs::remove_file(dir.join(format!("{uid}.figcraft"))); let _ = std::fs::remove_file(dir.join(format!("{uid}.{ONLINE}"))); } @@ -172,12 +244,20 @@ impl Recovery { }); } + /// Test hook: pretend a write of `uid` at `revision` is in flight in `writer`. + #[cfg(test)] + pub fn hold(&mut self, uid: u64, revision: u64, writer: JoinHandle) { + self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) }); + } + /// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier /// runs' copies still waiting for their outcome stay for the next start.) pub fn finish(self) { - let Self { dir, _lock, written, .. } = self; + let Self { dir, _lock, written, waiting, .. } = self; + // The documents earlier runs' copies became were saved or discarded too. + waiting.into_iter().filter(|w| matches!(w.until, Until::Copied(_))).for_each(remove_kept); // Windows removes a folder only once nothing in it is open: copies being written first. - for h in written.into_values().filter_map(|w| w.1) { + for (_, h) in written.into_values().filter_map(|w| w.flight) { let _ = h.join(); } drop(_lock); @@ -185,6 +265,20 @@ impl Recovery { } } +/// Remove a kept copy and its note, and its folder with its last copy (while another is kept +/// there, its lock is held). +fn remove_kept(w: Waiting) { + let _ = std::fs::remove_file(&w.copy); + let _ = std::fs::remove_file(w.copy.with_extension(ONLINE)); + let last = w._lock.as_ref().is_some_and(|l| Arc::strong_count(l) == 1); + let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| last); + drop(w); + if let Some(dir) = dir { + let _ = std::fs::remove_file(dir.join(LOCK)); + let _ = std::fs::remove_dir(dir); + } +} + /// The extension of the note beside the copy of a document that is one of your online files. const ONLINE: &str = "online"; @@ -201,15 +295,27 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option { s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64() } -/// A document whose changes would be lost (a branch tab's edits are in its main file). -fn needs_copy(d: &figcraft_engine::DocState) -> bool { - d.is_dirty() && d.branch_of.is_none() +/// A file whose changes would be lost: in its document, its history or one of its branch tabs +/// (a branch tab's edits are saved with its main file, so the tab has no copy of its own). +fn needs_copy(s: &Session, d: &DocState) -> bool { + d.branch_of.is_none() && (d.is_dirty() || branch_tabs(s, d.uid).any(DocState::is_dirty)) } -/// Write `doc` to `path` through a temporary file; false when it couldn't be written (the copy -/// before it is still there). -fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) -> bool { - let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return false }; +/// The tabs showing branches of the file in tab `uid`. +fn branch_tabs(s: &Session, uid: u64) -> impl Iterator { + s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid)) +} + +/// The revisions a copy of `d` is of: its own and its branch tabs' (a branch tab's edits change +/// only its own revision). A new copy is due when they change. +fn stamp(s: &Session, d: &DocState) -> Vec { + std::iter::once(d.revision).chain(branch_tabs(s, d.uid).map(|t| t.revision)).collect() +} + +/// Write `doc` with its version history and branches to `path` through a temporary file; false +/// when it couldn't be written (the copy before it is still there). +fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool { + let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false }; let tmp = path.with_extension("tmp"); std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok() } @@ -222,9 +328,9 @@ pub fn recover(root: &Path, s: &mut Session) -> usize { } /// Open the copies left by runs that ended without closing (folders under `root` whose lock is -/// free, and copies directly in `root` from older versions) as unsaved documents, then remove them. -/// A copy that can't be read stays where it is. A copy of one of your online files is kept -/// instead while its file is joined again ([`Recovered::waiting`]). +/// free, and copies directly in `root` from older versions) as unsaved documents. Each is kept +/// until this run has its own copy of the document ([`Recovered::waiting`]); a copy of one of your +/// online files, while its file is joined again. A copy that can't be read stays where it is. pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { let mut out = Recovered::default(); let Ok(entries) = std::fs::read_dir(root) else { return out }; @@ -234,10 +340,15 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { } // One of your online files comes back from its server, its copy kept until then. if let Some((session, lock)) = lock.and_then(|l| Some((rejoin_online(file, s)?, l))) { - out.waiting.push(Waiting { session, copy: file.to_path_buf(), _lock: lock.clone() }); - } else if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() { + out.waiting.push(Waiting { until: Until::Session(session), copy: file.to_path_buf(), _lock: Some(lock.clone()) }); + return; + } + let before: Vec = s.docs.iter().map(|d| d.uid).collect(); + if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() { out.copies += 1; - let _ = std::fs::remove_file(file); + if let Some(uid) = s.docs.iter().map(|d| d.uid).find(|u| !before.contains(u)) { + out.waiting.push(Waiting { until: Until::Copied(uid), copy: file.to_path_buf(), _lock: lock.cloned() }); + } } }; for e in entries.flatten() { @@ -265,7 +376,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == ONLINE) && !f.with_extension("figcraft").exists()) { let _ = std::fs::remove_file(f); } - // Empty now unless a copy couldn't be read, or waits for its file (kept, its lock held). + // Empty now unless a copy couldn't be read, or is kept (its lock held). if Arc::strong_count(&lock) == 1 { drop(lock); let _ = std::fs::remove_file(path.join(LOCK)); diff --git a/apps/figcraft/src/recovery_online_tests.rs b/apps/figcraft/src/recovery_online_tests.rs index ead3749af..c9036773d 100644 --- a/apps/figcraft/src/recovery_online_tests.rs +++ b/apps/figcraft/src/recovery_online_tests.rs @@ -223,6 +223,30 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() { let _ = std::fs::remove_dir_all(&root); } +#[test] +fn an_online_copy_stays_while_what_it_became_cant_be_copied() { + let root = root("ahead-blocked"); + let (mut s, script, mut server, _) = shared(); + s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap(); + script.lock().unwrap().sent.clear(); + let left = crash(&s, &root); + let (mut next, script, mut r, _) = restart(&s, &root); + arrive(&mut next, &script, &mut server); + assert!(next.docs[1].is_dirty(), "the copy's edit is on top, not saved anywhere yet"); + // Something is in the way of this run's copy: writing it fails. + let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[1].uid)); + std::fs::create_dir_all(&mine).unwrap(); + r.tick(&next, 1.0); + assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1), "kept: the work is nowhere else on disk"); + // Once it can be written, the old copy goes. + std::fs::remove_dir(&mine).unwrap(); + next.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 4, "height": 4})).unwrap(); + r.tick(&next, 2.0); + assert!(!left.exists()); + drop(r); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn a_copy_the_server_already_has_is_discarded() { let root = root("equal"); @@ -332,7 +356,7 @@ fn without_its_record_the_copy_opens_as_any_other() { assert_eq!(recover(&root, &mut next), 1); assert!(script.lock().unwrap().joined.is_empty()); assert!(next.docs.first().is_some_and(|d| d.is_dirty() && d.path.is_none())); - assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone"); + assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own"); let _ = std::fs::remove_dir_all(&root); } diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index 95560319c..a3d608f9d 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -4,7 +4,7 @@ use figcraft_engine::Session; use serde_json::json; -use crate::recovery::{Recovery, recover}; +use crate::recovery::{Recovery, recover, recover_all}; fn root(name: &str) -> std::path::PathBuf { let r = std::env::temp_dir().join(format!("figcraft-recovery-{name}-{}", std::process::id())); @@ -34,7 +34,7 @@ fn a_crashed_runs_unsaved_documents_come_back() { let ds = next.docs.first().unwrap(); assert!(ds.is_dirty() && ds.path.is_none()); assert_eq!(next.doc().unwrap().node(next.doc().unwrap().pages[0]).map(|p| p.children.len()), Some(1)); - assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone"); + assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own"); let _ = std::fs::remove_dir_all(&root); } @@ -62,3 +62,289 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() { assert!(!dir.exists(), "a normal quit removes the copies"); let _ = std::fs::remove_dir_all(&root); } + +/// A run that ended without closing, leaving a copy of `s`'s unsaved document: its folder (as +/// another process's). +fn crashed(s: &Session, root: &std::path::Path) -> std::path::PathBuf { + let mut r = Recovery::start(root).unwrap(); + r.write(s, true); + drop(r); + let left = root.join("crashed"); + std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap(); + left +} + +fn copies_in(d: &std::path::Path) -> usize { + std::fs::read_dir(d).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count()) +} + +/// The next start: the copies come back, and this run's recovery keeps them until it covers them. +fn restart(root: &std::path::Path) -> (Session, Recovery) { + let mut next = Session::empty(); + let recovered = recover_all(root, &mut next); + assert_eq!(recovered.copies, 1); + let mut r = Recovery::start(root).unwrap(); + r.adopt(recovered.waiting); + (next, r) +} + +#[test] +fn a_recovered_copy_stays_until_this_run_has_its_own_copy() { + let root = root("handoff"); + let left = crashed(&edited(), &root); + let (next, mut r) = restart(&root); + assert_eq!(copies_in(&left), 1, "the only copy on disk until this run writes one"); + r.write(&next, true); + r.tick(&next, 100.0); + assert!(!left.exists(), "covered by this run's copy, the old one goes"); + assert_eq!(copies_in(&root.join(std::process::id().to_string())), 1); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_recovered_copy_stays_while_its_replacement_cant_be_written() { + let root = root("blocked"); + let left = crashed(&edited(), &root); + let (next, mut r) = restart(&root); + // Something is in the way of this run's copy: writing it fails. + let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[0].uid)); + std::fs::create_dir_all(&mine).unwrap(); + r.write(&next, true); + r.tick(&next, 100.0); + assert_eq!(copies_in(&left), 1, "kept: the work is nowhere else on disk"); + // Once it can be written, the old copy goes. + std::fs::remove_dir(&mine).unwrap(); + let mut next = next; + next.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 2, "height": 2})).unwrap(); + r.write(&next, true); + r.tick(&next, 200.0); + assert!(!left.exists()); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn without_a_recovery_of_its_own_the_copy_stays_for_the_next_start() { + let root = root("nocopies"); + crashed(&edited(), &root); + let mut next = Session::empty(); + let recovered = recover_all(&root, &mut next); + assert_eq!(recovered.copies, 1); + // This run couldn't take a folder of its own: it ends (or crashes) with nothing copied. + drop(recovered); + let mut after = Session::empty(); + assert_eq!(recover(&root, &mut after), 1, "the work comes back again"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_recovered_copy_goes_once_its_document_is_saved_or_closed() { + for close in [false, true] { + let root = root(if close { "closed" } else { "saved" }); + let left = crashed(&edited(), &root); + let (mut next, mut r) = restart(&root); + if close { + next.docs.clear(); + } else { + next.ds_mut().unwrap().mark_saved(); + } + r.tick(&next, 0.0); + assert!(!left.exists(), "close {close}"); + r.finish(); + let _ = std::fs::remove_dir_all(&root); + } +} + +#[test] +fn a_normal_quit_removes_the_recovered_copies_it_took_over() { + let root = root("quitafter"); + let left = crashed(&edited(), &root); + let (_, r) = restart(&root); + // Quitting asked to save or discard every document. + r.finish(); + assert!(!left.exists()); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_run_whose_folder_name_is_still_held_takes_another() { + let root = root("pidreuse"); + // A crashed run with this process's id whose folder the start still holds (its copy waits). + let held = root.join(std::process::id().to_string()); + std::fs::create_dir_all(&held).unwrap(); + let lock = std::fs::File::create(held.join("lock")).unwrap(); + lock.try_lock().unwrap(); + let mut r = Recovery::start(&root).expect("a folder of its own"); + let s = edited(); + r.write(&s, true); + assert_eq!(copies_in(&held), 0); + let dirs = std::fs::read_dir(&root).unwrap().flatten().filter(|e| e.path() != held).count(); + assert_eq!(dirs, 1); + r.finish(); + drop(lock); + let _ = std::fs::remove_dir_all(&root); +} + +/// The rectangles in `doc`. +fn rectangles(doc: &figcraft_engine::doc::Document) -> usize { + doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count() +} + +/// The documents a crashed run's copies under `root` open as. +fn crash_and_recover(r: Recovery, root: &std::path::Path) -> Session { + drop(r); + let mut next = Session::empty(); + assert_eq!(recover(root, &mut next), 1); + next +} + +#[test] +fn copies_keep_the_version_history_and_branches() { + // #185: a copy has what a save would write, the latest edits on a branch tab included. + let root = root("history"); + let mut s = edited(); + s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap(); + s.execute("branch.create", &json!({"name": "Explore"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + let mut r = Recovery::start(&root).unwrap(); + r.write(&s, true); + let next = crash_and_recover(r, &root); + let ds = next.docs.first().unwrap(); + assert_eq!(ds.versions.iter().map(|v| v.name.as_str()).collect::>(), ["v1"]); + assert_eq!(ds.branches.iter().map(|b| (b.name.as_str(), rectangles(&b.head))).collect::>(), [("Explore", 2)]); + let _ = std::fs::remove_dir_all(&root); +} + +/// The one copy under `root` (the run's folder), read back. +fn copy(root: &std::path::Path) -> figcraft_format::Loaded { + let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path(); + let file = std::fs::read_dir(dir).unwrap().flatten().map(|f| f.path()).find(|f| f.extension().is_some_and(|e| e == "figcraft")).unwrap(); + figcraft_format::load_bytes_with_versions(&std::fs::read(file).unwrap()).unwrap() +} + +#[test] +fn a_change_only_on_a_branch_tab_or_to_the_history_is_copied() { + let root = root("history-only"); + let mut s = edited(); + s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap(); + s.execute("branch.create", &json!({"name": "Explore"})).unwrap(); + for d in &mut s.docs { + d.mark_saved(); + } + let mut r = Recovery::start(&root).unwrap(); + // An edit on the branch tab only: the main file has no changes of its own yet. + s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + r.write(&s, true); + assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::>(), [2]); + s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap(); + r.write(&s, true); + assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::>(), [3]); + // A version renamed on the main file leaves its document as it was. + s.execute("doc.switch", &json!({"index": 0})).unwrap(); + r.write(&s, true); + let id = s.docs[0].versions[0].id.clone(); + s.execute("version.rename", &json!({"id": id, "name": "v1 (renamed)"})).unwrap(); + r.write(&s, true); + assert_eq!(copy(&root).versions.iter().map(|v| v.name.as_str()).collect::>(), ["v1 (renamed)"]); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} + +fn slow_write(path: std::path::PathBuf) -> std::thread::JoinHandle { + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(150)); + std::fs::write(path, b"stale").is_ok() + }) +} + +#[test] +fn saving_or_closing_waits_for_a_slow_write_so_the_copy_does_not_come_back() { + let root = root("slow-cleanup"); + let mut s = edited(); + let mut r = Recovery::start(&root).unwrap(); + let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); + let uid = s.docs[0].uid; + let copy = dir.join(format!("{uid}.figcraft")); + r.hold(uid, 1, slow_write(copy.clone())); + s.ds_mut().unwrap().mark_saved(); + r.write(&s, false); + std::thread::sleep(std::time::Duration::from_millis(400)); + assert!(!copy.exists(), "a saved document's copy stays removed"); + // Closed: the same. + let s = edited(); + let uid = s.docs[0].uid; + let copy = dir.join(format!("{uid}.figcraft")); + r.hold(uid, 1, slow_write(copy.clone())); + r.write(&Session::empty(), false); + std::thread::sleep(std::time::Duration::from_millis(400)); + assert!(!copy.exists(), "a closed document's copy stays removed"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn an_emergency_save_waits_for_the_write_in_flight_and_copies_the_newest_revision() { + let root = root("slow-emergency"); + let s = edited(); + let mut r = Recovery::start(&root).unwrap(); + let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path(); + let uid = s.docs[0].uid; + let copy = dir.join(format!("{uid}.figcraft")); + r.hold(uid, 0, slow_write(copy.clone())); + r.write(&s, true); + assert!(std::fs::read(©).is_ok_and(|b| b != b"stale"), "the newest revision replaced the slow write"); + let _ = std::fs::remove_dir_all(&root); +} + +/// The files named `*.figcraft` in the run folder under `root`. +fn copy_count(root: &std::path::Path) -> usize { + let Some(dir) = std::fs::read_dir(root).unwrap().flatten().next().map(|e| e.path()) else { return 0 }; + std::fs::read_dir(dir).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count() +} + +/// A folder at the path of `s`'s first document's copy in the run folder under `root`. +fn blocked_copy(root: &std::path::Path, s: &Session) -> std::path::PathBuf { + let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path(); + let blocker = dir.join(format!("{}.figcraft", s.docs[0].uid)); + std::fs::create_dir(&blocker).unwrap(); + blocker +} + +#[test] +fn a_copy_that_failed_is_tried_again_without_another_edit() { + let root = root("retry"); + let s = edited(); + let mut r = Recovery::start(&root).unwrap(); + // A folder where the copy goes makes its write fail on every platform (renaming the run folder + // away fails on Windows, where the run's lock file keeps it open). + let blocker = blocked_copy(&root, &s); + r.write(&s, true); + std::fs::remove_dir(&blocker).unwrap(); + // Nothing was edited since: the same revision still has to be copied. + r.write(&s, true); + assert_eq!(copy_count(&root), 1, "the failed revision was copied again"); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_background_copy_that_failed_is_tried_again_without_another_edit() { + let root = root("retry-bg"); + let s = edited(); + let mut r = Recovery::start(&root).unwrap(); + let blocker = blocked_copy(&root, &s); + r.write(&s, false); + // Let the failing write end before the way is clear again. + std::thread::sleep(std::time::Duration::from_millis(300)); + std::fs::remove_dir(&blocker).unwrap(); + for _ in 0..50 { + r.write(&s, false); + if copy_count(&root) == 1 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + assert_eq!(copy_count(&root), 1, "the failed revision was copied again"); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index f4aeb63d7..bd8a42043 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -1,11 +1,12 @@ //! Desktop services: native file dialogs, clipboard, URLs, persisted preferences. +use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use figcraft_engine::{Prefs, Session}; -use figcraft_ui_egui::{FigApp, Services}; +use figcraft_ui_egui::{DialogFuture, FigApp, Services}; use serde_json::{Value, json}; pub struct Desktop { @@ -59,12 +60,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() } @@ -194,15 +199,39 @@ fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { f.write_all(bytes) } +thread_local! { + /// While the main window draws: a dialog parented to it, so a dialog opened from it stays in + /// front of it (a sheet on macOS). eframe gives other windows no handle: theirs have no parent. + static PARENT: RefCell> = const { RefCell::new(None) }; +} + +/// Run the main window's frame `f` with the dialogs it opens parented to `frame`'s window. +pub fn parented(frame: &eframe::Frame, f: impl FnOnce() -> R) -> R { + PARENT.with(|p| p.replace(Some(rfd::AsyncFileDialog::new().set_parent(frame)))); + let r = f(); + PARENT.with(|p| p.replace(None)); + r +} + +/// A dialog with `title`, parented to the main window while it draws. +fn dialog(title: &str) -> rfd::AsyncFileDialog { + PARENT.with(|p| p.borrow().clone()).unwrap_or_default().set_title(title) +} + +/// The dialog opens now; its future gives the path picked. +fn picked(pick: impl Future> + 'static) -> DialogFuture { + Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) }) +} + impl Services for Desktop { - fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option { - rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file() + fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { + picked(dialog(title).add_filter("Supported files", exts).pick_file()) } - fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> Option { - rfd::FileDialog::new().set_title(title).set_file_name(name).add_filter("File", exts).save_file() + fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { + picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file()) } - fn pick_folder(&mut self, title: &str) -> Option { - rfd::FileDialog::new().set_title(title).pick_folder() + fn pick_folder(&mut self, title: &str) -> DialogFuture { + picked(dialog(title).pick_folder()) } fn clipboard_set_text(&mut self, s: &str) { if let Some(c) = self.clipboard.as_mut() { @@ -242,6 +271,8 @@ impl Services for Desktop { fn emergency_save(&mut self, session: &Session) { if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() { r.write(session, true); + // Earlier runs' copies now covered go, so the next start doesn't open them twice. + r.settle(session); } } fn reduce_motion(&self) -> bool { diff --git a/apps/figcraft/src/startup.rs b/apps/figcraft/src/startup.rs new file mode 100644 index 000000000..a25821f3f --- /dev/null +++ b/apps/figcraft/src/startup.rs @@ -0,0 +1,27 @@ +//! When the window can't open. +//! +//! Release builds on Windows have no console, so an error from `eframe::run_native` (no usable +//! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees +//! and the app quit without a word (#261). Now it is logged and shown in a message box. + +/// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would +/// keep the caller waiting, and it reads standard error), show it in a message box. +pub fn failed(error: &eframe::Error, quiet: bool) { + // In the interface language: the text and its catalog entries are the UI crate's. + let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some()); + // Standard error even when RUST_LOG turned errors off. + if log::log_enabled!(log::Level::Error) { + log::error!("{text}"); + } else { + eprintln!("figcraft: {text}"); + } + if quiet { + return; + } + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("FigCraft") + .set_description(text) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} diff --git a/apps/figcraft/src/startup_tests.rs b/apps/figcraft/src/startup_tests.rs new file mode 100644 index 000000000..0c92bd19b --- /dev/null +++ b/apps/figcraft/src/startup_tests.rs @@ -0,0 +1,35 @@ +//! The message shown when the window can't open: it carries the error, a next step and where to +//! report it, instead of the app quitting silently (#261). + +use figcraft_ui_egui::dialogs::window_failed as message; + +const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}"; + +#[test] +fn the_message_carries_the_error_and_where_to_report_it() { + let text = message(ERROR, false); + assert!(text.contains(ERROR), "the error itself: {text}"); + assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}"); +} + +#[test] +fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() { + let text = message(ERROR, false); + assert!(text.contains("graphics driver"), "{text}"); + assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}"); + assert!(!text.contains("unsetting"), "{text}"); +} + +#[cfg(windows)] +#[test] +fn on_windows_it_shows_how_to_start_with_opengl() { + let text = message(ERROR, false); + assert!(text.contains("set WGPU_BACKEND=gl"), "a release build has no console to look this up from: {text}"); +} + +#[test] +fn with_wgpu_backend_set_it_suggests_unsetting_it() { + let text = message(ERROR, true); + assert!(text.contains("unsetting"), "{text}"); + assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}"); +} diff --git a/contributors/people.toml b/contributors/people.toml index e61cfd31a..e744e31c2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -16,3 +16,26 @@ # # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. + +[people.VanTan666] +display_name = "VanTan" + +[people.tylerjenningsw] +real_name = "Tyler Jennings" +display_name = "tylerjenningsw" + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false + +[people.yyamandevrim] +real_name = "Yaman Devrim" +sync_github_name = true + +[people.justinjohn0306] +real_name = "Justin John" + +[people.Losminek] + +[people.RevDonz] +sync_github_name = true diff --git a/crates/collab-net/src/barred_tests.rs b/crates/collab-net/src/barred_tests.rs new file mode 100644 index 000000000..c9355a5fa --- /dev/null +++ b/crates/collab-net/src/barred_tests.rs @@ -0,0 +1,53 @@ +//! Addresses removed from a file are kept only while its session lasts, so a long-running server +//! doesn't collect them for every file it ever had open. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr}; + +use figcraft_collab::{Limits, Server}; +use figcraft_doc::Document; + +use super::{Admission, Backend, Closing, Hub, InFlight, Room}; + +/// A backend that closes a file as soon as everyone left it. +struct Closes; + +impl Backend for Closes { + fn admit(&mut self, _file: &str, _token: &str) -> Result { + Err("no".into()) + } + + fn open(&mut self, _file: &str) -> Result { + Err("no".into()) + } + + fn idle(&mut self, _file: &str, _server: &mut Server) -> bool { + true + } + + fn closing(&self) -> Closing { + Closing::Restarting + } +} + +#[test] +fn a_closed_file_forgets_whom_it_barred() { + let mut doc = Document::new("File"); + doc.repair(); + let mut hub = Hub { + in_flight: InFlight(Default::default()), + backend: Closes, + rooms: HashMap::from([("f".to_string(), Room::new(Server::new(doc, "", Limits::default())))]), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + barred: HashMap::new(), + }; + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7)); + hub.barred.entry("f".into()).or_default().insert(ip); + hub.barred.entry("other".into()).or_default().insert(ip); + hub.close_if_empty("f"); + assert!(!hub.rooms.contains_key("f"), "everyone left: the file closed"); + assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it"); + assert!(hub.barred.contains_key("other"), "another file's stay"); +} diff --git a/crates/collab-net/src/host.rs b/crates/collab-net/src/host.rs index 0d51735e0..24be22d3e 100644 --- a/crates/collab-net/src/host.rs +++ b/crates/collab-net/src/host.rs @@ -11,6 +11,7 @@ use figcraft_collab::{Outgoing, Server, same_secret}; use figcraft_doc::Document; use figcraft_engine::live::{Event, PEOPLE}; +pub use crate::hub::REMOVED; use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local}; use crate::inbox::Inbox; pub use crate::join::ENDED; @@ -129,6 +130,12 @@ impl Host { self.hosting.send(msg); } + /// Remove client `id` from the session: they are disconnected and can't join again from + /// their address while it runs. The host can't remove themselves. + pub fn remove(&self, id: u32) { + self.hosting.remove(id); + } + /// Cut every connection as a network failure would (no close frame). #[cfg(test)] pub(crate) fn cut_connections(&self) { diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 24474211f..5cf3e5191 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -21,7 +21,7 @@ //! backend may refuse addresses or limit what they do. A plain `GET /healthz` on the same port is //! answered `200 ok` for uptime checks. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::{self, Read, Write}; use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -413,6 +413,8 @@ enum HubIn { }, Local(Presence), LocalMsg(ClientMsg), + /// The person hosting removes client `id` from their file (app sessions). + Remove(u32), /// Cut every connection without a word (tests: a network failure). #[cfg(test)] Cut, @@ -461,7 +463,15 @@ impl Hosting { pub(crate) fn start(listener: TcpListener, backend: B, limits: NetLimits, local: Option) -> io::Result { let addr = listener.local_addr()?; let in_flight = InFlight(Arc::default()); - let mut hub = Hub { backend, rooms: HashMap::new(), conns: HashMap::new(), local: None, outbox: Vec::new(), in_flight: in_flight.clone() }; + let mut hub = Hub { + backend, + rooms: HashMap::new(), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + in_flight: in_flight.clone(), + barred: HashMap::new(), + }; if let Some(l) = local { let mut room = Room::new(l.server); room.local = Some((l.id, l.inbox)); @@ -551,6 +561,12 @@ impl Hosting { let _ = self.hub.send(HubIn::LocalMsg(msg)); } + /// The hosting person removes client `id` from their file (app sessions; see + /// [`HubIn::Remove`]). + pub(crate) fn remove(&self, id: u32) { + let _ = self.hub.send(HubIn::Remove(id)); + } + /// Cut every connection as a network failure would (no close frame). #[cfg(test)] pub(crate) fn cut_connections(&self) { @@ -1108,6 +1124,16 @@ fn unsupported(protocol: u32) -> String { /// sharing): the session's end for them. pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open"; +/// What someone removed from a file is told: the session's end for them (they keep their copy; +/// nothing retries). +pub const REMOVED: &str = "You were removed from this session. Your copy stays open"; + +/// Why a removed person's address can't join the file again. +const BARRED: &str = "you were removed from this session"; + +/// The most addresses one file keeps barred. +const MAX_BARRED: usize = 256; + /// What everyone in a file is told when its changes couldn't be saved (they reconnect). const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting"; @@ -1121,6 +1147,9 @@ struct Hub { local: Option, /// What the servers said, sent once the backend made what changed durable. outbox: Vec<(String, Outgoing)>, + /// Addresses ([`ip_key`]) removed from each open file: they can't join it again until its + /// session closes (the file is closed or removed; the entry goes with it). + barred: HashMap>, } impl Hub { @@ -1208,6 +1237,13 @@ impl Hub { self.client_message(&file, id, msg, None); } } + HubIn::Remove(id) => { + if let Some(file) = self.local.clone() + && let Err(why) = self.remove_peer(&file, id) + { + log::info!("live session: couldn't remove client {id}: {why}"); + } + } #[cfg(test)] HubIn::Cut => { for c in self.conns.values() { @@ -1232,6 +1268,14 @@ impl Hub { self.client_message(&file, id, msg, Some(bytes)); } Some(Kind::Hosting { owns }) => match msg { + ClientMsg::Admin(Admin::RemovePeer { file, client }) => { + let reply = if owns.as_deref() == Some(file.as_str()) { + self.removed_reply(&file, client) + } else { + AdminReply::Failed { reason: "an owner token manages only its own file".into() } + }; + self.answer(conn, &ServerMsg::Admin(Box::new(reply))); + } ClientMsg::Admin(req) => { let owns = owns.clone(); let rooms = &self.rooms; @@ -1243,6 +1287,10 @@ impl Hub { _ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"), }, Some(Kind::Managing) => match msg { + ClientMsg::Admin(Admin::RemovePeer { file, client }) => { + let reply = self.removed_reply(&file, client); + self.answer(conn, &ServerMsg::Admin(Box::new(reply))); + } ClientMsg::Admin(req) => self.admin(conn, req), ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), _ => self.kick(&[conn], "only requests to manage files are spoken on this connection"), @@ -1305,6 +1353,10 @@ impl Hub { Ok(a) => a, Err(why) => return refuse(&c.out, &why), }; + if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) { + log::info!("event=refused ip={} reason=removed", c.ip); + return refuse(&c.out, BARRED); + } let hosting = reads_hosting(&hello.client_version); // The backend checked the secret: the sessions don't keep it. hello.token.clear(); @@ -1362,6 +1414,34 @@ impl Hub { } } + /// Remove client `id` from `file`: they are told the session is over for them and + /// disconnected, and their address can't join the file again while its session lasts. The + /// person hosting can't be removed. + fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> { + let room = self.rooms.get(file).ok_or("nobody is in this file")?; + if room.local.as_ref().is_some_and(|(local, _)| *local == id) { + return Err("the person hosting can't be removed".into()); + } + let conn = *room.ids.get(&id).ok_or_else(|| format!("nobody with client id {id} is in this file"))?; + let c = self.conns.get(&conn).ok_or_else(|| format!("nobody with client id {id} is in this file"))?; + let barred = self.barred.entry(file.to_string()).or_default(); + if barred.len() < MAX_BARRED { + barred.insert(c.ip); + } + log::info!("event=removed file={file} client={id} ip={}", c.ip); + let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())); + self.gone(conn); + Ok(()) + } + + /// [`remove_peer`](Self::remove_peer) as a managing connection's answer. + fn removed_reply(&mut self, file: &str, id: u32) -> AdminReply { + match self.remove_peer(file, id) { + Ok(()) => AdminReply::Done, + Err(reason) => AdminReply::Failed { reason }, + } + } + /// Take in connection `c` as `kind` and send it `first`. fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) { self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip }); @@ -1479,6 +1559,7 @@ impl Hub { .collect(); // Closed without saving: the file is gone. self.rooms.remove(&file); + self.barred.remove(&file); self.kick(&users, "this file was removed from the server"); } if let Some(ip) = handled.blocked { @@ -1556,6 +1637,7 @@ impl Hub { let Some(room) = self.rooms.get_mut(file).filter(|r| r.empty()) else { return }; if self.backend.idle(file, &mut room.server) { self.rooms.remove(file); + self.barred.remove(file); } } @@ -1661,3 +1743,7 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) { #[cfg(test)] #[path = "queue_tests.rs"] mod queue_tests; + +#[cfg(test)] +#[path = "barred_tests.rs"] +mod barred_tests; diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 754f15298..a84395aa8 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -243,6 +243,18 @@ impl LiveLink for NetLink { } } + fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> { + match self.sessions.get(session) { + Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()), + Some(Active::Host(h)) => { + h.remove(client); + Ok(()) + } + Some(Active::Guest(_)) => Err("only the person hosting can remove people from this session".into()), + None => Err("this session is over".into()), + } + } + fn leave(&mut self, session: SessionId) { if let Some(active) = self.sessions.remove(session) { active.end(); diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 814d00044..32c86bd0c 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -15,7 +15,7 @@ use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; use crate::guest::{Guest, GuestConfig}; -use crate::host::{ENDED, Host, HostConfig}; +use crate::host::{ENDED, Host, HostConfig, REMOVED}; use crate::inbox::Inbox; use crate::invite::Invite; use crate::net::{Bucket, NetLimits}; @@ -611,3 +611,34 @@ fn until_presence_or_pong(r: &mut Raw) -> Option { } } } + +#[test] +fn the_host_removes_someone_and_they_cant_come_back() { + let (mut host, mut hp, invite) = start(); + let (_a, mut ap) = join(&invite, "Ada"); + let (b, mut bp) = join(&invite, "Bo"); + ap.wait("welcome", welcomed); + bp.wait("welcome", welcomed); + let (ada, bo) = (ap.me(), bp.me()); + connections_settle(&host, 2); + // The host can't remove themselves, nor someone who isn't there: nothing happens. + host.remove(host.me().client_id); + host.remove(9999); + host.remove(ada); + // Ada is told the session is over for her (no reconnecting); the others see her leave. + ap.wait("Ada removed", |e| e.iter().any(|e| matches!(e, Event::Ended { reason } if reason == REMOVED))); + hp.wait("the host sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada))); + bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada))); + connections_settle(&host, 1); + assert!(!ap.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a removed guest doesn't reconnect"); + assert!(!hp.left().contains(&bo), "only Ada was removed"); + // Her address can't join again with the same invite (every guest here comes from 127.0.0.1). + let (_again, mut againp) = join(&invite, "Ada"); + againp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("removed")))); + assert!(!welcomed(&againp.events)); + // Bo, already in, stays and can still talk to the host. + b.presence(presence(3.0, 4.0)); + hp.wait("Bo's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == bo))); + host.stop(); + assert_eq!(host.connections(), 0); +} diff --git a/crates/collab/src/client.rs b/crates/collab/src/client.rs index a2c6e7449..8f2a98419 100644 --- a/crates/collab/src/client.rs +++ b/crates/collab/src/client.rs @@ -307,7 +307,8 @@ impl Client { /// Queue `changes` as a new batch (already shown in the view). fn queue(&mut self, changes: Vec, kind: BatchKind, step: Option) { let batch_id = self.next_batch; - self.next_batch += 1; + // `next_batch` starts from the welcome's `last_batch`, the host's word: saturate. + self.next_batch = self.next_batch.saturating_add(1); self.pending.push_back(Pending { batch_id, base_seq: self.seq, changes, kind, step, rebased: false, sent: false }); } @@ -484,7 +485,11 @@ impl Client { /// A batch the server accepted (in order). Follow-ups it causes are queued. fn accepted(&mut self, acc: Accepted, recompute: &dyn Recompute) -> Result<(), Error> { - if acc.seq != self.seq + 1 { + // The host's sequence number is untrusted: at `u64::MAX` it can't step past it. + let Some(next) = self.seq.checked_add(1) else { + return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq))); + }; + if acc.seq != next { return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq))); } if acc.author != self.id { diff --git a/crates/collab/src/client_tests.rs b/crates/collab/src/client_tests.rs new file mode 100644 index 000000000..67e1e7a1d --- /dev/null +++ b/crates/collab/src/client_tests.rs @@ -0,0 +1,43 @@ +//! A client's handling of the host's messages: sequence numbers and batch ids come from the host, +//! so they are treated as hostile input (they must never overflow). + +use figcraft_doc::NodeKind; + +use crate::message::{Accepted, ClientMsg, Hello, ServerMsg}; +use crate::sim_tests::base; +use crate::{Client, Limits, NoRecompute, Outgoing, Server}; + +fn hello() -> Hello { + let ClientMsg::Hello(h) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") }; + h +} + +/// A welcome whose sequence numbers are what a lying host would send. +fn hostile_welcome(seq: u64, last_batch: u64) -> ServerMsg { + let mut s = Server::new(base(), "tok", Limits::default()); + let (_, out) = s.join(hello()).unwrap(); + let mut welcome = out + .into_iter() + .find_map(|o| match o { + Outgoing::To(_, m) => Some(m), + _ => None, + }) + .unwrap(); + let ServerMsg::Welcome { seq: seq0, processed, .. } = &mut welcome else { panic!("no welcome") }; + *seq0 = seq; + processed.last_batch = last_batch; + welcome +} + +#[test] +fn hostile_sequence_numbers_do_not_overflow_the_client() { + let mut c = Client::join(hostile_welcome(u64::MAX, u64::MAX)).unwrap(); + // This client's own first batch must not overflow its batch counter. + let mut d = c.document().clone(); + let page = d.first_page().unwrap(); + d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + assert!(c.edit(&d).unwrap().is_some(), "the edit is batched"); + // A batch the host claims follows `u64::MAX` can't exist: refuse it, don't overflow. + let acc = Accepted { seq: 0, author: c.id().wrapping_add(1), batch_id: 0, changes: Vec::new() }; + assert!(c.receive(ServerMsg::Changes(acc), &NoRecompute).is_err()); +} diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 849319047..9f5d12fb9 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -75,6 +75,7 @@ fn samples() -> (Vec, Vec) { ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }), ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }), ClientMsg::Admin(Admin::Blocked), + ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }), ClientMsg::Report { reason: "Spam ☕".into() }, ]; let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap(); diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 502d8a3e4..2c693bc41 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -123,6 +123,8 @@ impl Recompute for NoRecompute { fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet) {} } +#[cfg(test)] +mod client_tests; #[cfg(test)] mod codec_tests; #[cfg(test)] diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 0eb0bd8c7..d035042b2 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -274,6 +274,10 @@ pub enum Admin { /// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them: /// [`AdminReply::Owned`]. Servers older than this request can't read it. Owned { files: Vec<(String, String)> }, + /// Remove client `client` from `file`'s session: they are disconnected and their address + /// can't join the file again while its session lasts. With the server key, or on an open + /// hosting connection with `file`'s owner token. Servers older than this request can't read it. + RemovePeer { file: String, client: u32 }, } /// The most files one [`Admin::Owned`] asks about. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3ae8bbe8a..513800e37 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -903,15 +903,22 @@ impl Document { /// A unique name like "Frame 3" for a new node of a kind. pub fn next_name(&self, base: &str) -> String { let mut max = 0u64; + let mut taken = std::collections::BTreeSet::new(); for n in self.nodes() { if let Some(rest) = n.name.strip_prefix(base) && let Some(num) = rest.strip_prefix(' ') && let Ok(k) = num.parse::() { max = max.max(k); + taken.insert(k); } } - format!("{base} {}", max + 1) + let next = match max.checked_add(1) { + Some(next) => next, + // The counter is spent: the lowest free number instead of wrapping onto a name. + None => (1u64..).find(|k| !taken.contains(k)).unwrap_or(1), + }; + format!("{base} {next}") } pub fn style(&self, id: &str) -> Option<&Style> { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index ef2f37b93..d54bb29b9 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -335,6 +335,16 @@ fn next_name_increments() { assert_eq!(d.next_name("Ellipse"), "Ellipse 1"); } +#[test] +fn next_name_never_overflows() { + let mut d = Document::new("x"); + let p = page(&d); + d.create(p, NodeKind::Frame, "Frame 18446744073709551615", 0.0, 0.0, 1.0, 1.0).unwrap(); + assert_eq!(d.next_name("Frame"), "Frame 1", "the counter can't step past u64::MAX"); + d.create(p, NodeKind::Frame, "Frame 1", 0.0, 0.0, 1.0, 1.0).unwrap(); + assert_eq!(d.next_name("Frame"), "Frame 2"); +} + #[test] fn custom_easings() { use crate::proto::Easing; diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index a02c661c1..1e8ba30fe 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -73,3 +73,52 @@ fn autosaves_keep_their_flag_in_the_file() { assert!(!autosave(&mut t)); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn files_that_save_themselves_still_get_checkpoints() { + let dir = std::env::temp_dir().join(format!("figcraft-autosave-on-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("auto.figcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("pref.autoSave", &json!({"on": true})).unwrap(); + rect(&mut s, 0.0); + s.execute("file.save", &json!({"path": path})).unwrap(); + assert!(autosave(&mut s), "saved, but changed since the last version"); + assert!(!autosave(&mut s), "no change since the checkpoint"); + // Just opened: nothing to checkpoint. + let mut t = Session::empty(); + t.execute("pref.autoSave", &json!({"on": true})).unwrap(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert!(!autosave(&mut t)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_file_encoded_elsewhere_is_saved_as_of_its_snapshot() { + use crate::cmd::file::FileSnapshot; + let dir = std::env::temp_dir().join(format!("figcraft-snapshot-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("snap.figcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("file.save", &json!({"path": path})).unwrap(); + rect(&mut s, 0.0); + let file = FileSnapshot::of(s.ds().unwrap()); + let bytes = file.encode().unwrap(); + rect(&mut s, 50.0); + assert!(file.save(&mut s, &bytes).unwrap()); + assert!(s.ds().unwrap().is_dirty(), "the edit since the snapshot is still unsaved"); + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 10.0, "the file is the snapshot"); + // Saved meanwhile: the older bytes never overwrite the newer file. + let file = FileSnapshot::of(s.ds().unwrap()); + let bytes = file.encode().unwrap(); + rect(&mut s, 100.0); + s.execute("file.save", &json!({})).unwrap(); + assert!(!file.save(&mut s, &bytes).unwrap()); + assert!(!s.ds().unwrap().is_dirty()); + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 110.0); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/batch_rollback_tests.rs b/crates/engine/src/batch_rollback_tests.rs index db0b79622..12d958cda 100644 --- a/crates/engine/src/batch_rollback_tests.rs +++ b/crates/engine/src/batch_rollback_tests.rs @@ -61,3 +61,25 @@ fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() assert_eq!(s.journal, before_journal); assert_eq!(s.message, before_message); } + +#[test] +fn failed_group_keeps_the_redo_steps() { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.undo().unwrap()); + assert_eq!(s.undo_depth().1, 1); + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}}, + {"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.undo_depth().1, 1, "the redo step survives the failed batch"); + assert!(s.redo().unwrap()); +} diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs new file mode 100644 index 000000000..ac69118e8 --- /dev/null +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -0,0 +1,112 @@ +use serde_json::json; + +use crate::{EngineError, Session}; + +fn id(value: &serde_json::Value) -> crate::NodeId { + crate::params::id_of(&value["id"]).unwrap() +} + +#[test] +fn later_steps_target_layers_created_by_earlier_steps() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({ + "label": "Card", + "commands": [ + {"command": "node.create", "params": {"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}}, + {"command": "node.set", "params": {"ids": [{"$step": 1}], "props": {"name": "Swatch"}}} + ] + }), + ) + .unwrap(); + + let frame = id(&results[0]); + let rect = id(&results[1]); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(rect).unwrap().parent, Some(frame)); + assert_eq!(doc.node(rect).unwrap().name, "Swatch"); + assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step"); +} + +#[test] +fn field_picks_another_part_of_the_result() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "params": {"type": "frame", "width": 50, "height": 50}}, + {"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0, "field": "id"}}} + ]}), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().node(id(&results[1])).unwrap().parent, Some(id(&results[0]))); +} + +#[test] +fn bad_references_fail_and_roll_back() { + let mut s = Session::new(); + let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(); + let frame = json!({"command": "node.create", "params": {"type": "frame", "width": 10, "height": 10}}); + for (bad, needle) in [ + (json!({"$step": 1}), "earlier step"), + (json!({"$step": 5}), "earlier step"), + (json!({"$step": -1}), "step index"), + (json!({"$step": "0"}), "step index"), + (json!({"$step": 0, "field": "nope"}), "no `nope`"), + (json!({"$step": 0, "field": 3}), "`field` must be a string"), + (json!({"$step": 0, "extra": true}), "not `extra`"), + ] { + let r = s.execute("command.batch", &json!({"commands": [frame, {"command": "node.set", "params": {"ids": [bad], "props": {"name": "x"}}}]})); + match r { + Err(EngineError::BadParams { msg, .. }) => assert!(msg.contains(needle), "{msg} should mention {needle}"), + other => panic!("expected BadParams for {bad}, got {other:?}"), + } + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back"); + } +} + +#[test] +fn deep_params_are_refused_not_overflowed() { + let mut s = Session::new(); + let mut deep = json!(1); + for _ in 0..200 { + deep = json!([deep]); + } + let r = s.execute("command.batch", &json!({"commands": [{"command": "select.none", "params": {"x": deep}}]})); + assert!(matches!(r, Err(EngineError::BadParams { .. }))); +} + +#[test] +fn text_that_looks_like_a_reference_is_left_alone() { + let mut s = Session::new(); + let results = s + .execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": "$step 0 {\"$step\": 0}"}}]})) + .unwrap(); + let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone(); + assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == "$step 0 {\"$step\": 0}")); +} + +#[test] +fn a_nested_batch_refers_to_its_own_steps() { + let mut s = Session::new(); + let frame = |name: &str| json!({"command": "node.create", "params": {"type": "frame", "name": name, "width": 100, "height": 100}}); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + frame("Outer"), + {"command": "command.batch", "params": {"commands": [ + frame("Inner"), + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "width": 10, "height": 10}} + ]}} + ]}), + ) + .unwrap(); + let inner = &results[1]; + let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); + assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one"); +} diff --git a/crates/engine/src/branches.rs b/crates/engine/src/branches.rs index 75065b82b..8bff0568f 100644 --- a/crates/engine/src/branches.rs +++ b/crates/engine/src/branches.rs @@ -156,6 +156,22 @@ pub(crate) fn sync(s: &mut Session) { } } +/// The branches of the file in tab `uid` as saving it now would write them: [`sync`] only puts a +/// branch tab's latest edits in before the next command. +pub fn current(s: &Session, uid: u64) -> Vec { + let Some(ds) = s.docs.iter().find(|d| d.uid == uid) else { return Vec::new() }; + let mut branches = ds.branches.clone(); + for d in &s.docs { + let Some((_, id)) = d.branch_of.as_ref().filter(|(main, _)| *main == uid) else { continue }; + let Some(b) = branches.iter_mut().find(|b| b.id == *id) else { continue }; + b.head = d.document_itself().clone(); + if d.versions_changed { + b.versions = d.versions.clone(); + } + } + branches +} + /// The tab a save writes: the main file's for a branch tab, else the active tab. pub fn file_tab(s: &Session) -> usize { s.ds().ok().and_then(|d| d.branch_of.as_ref()).and_then(|(m, _)| s.docs.iter().position(|d| d.uid == *m)).unwrap_or(s.active) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 2c088cfa1..43cfcf3c0 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 63442b3ca..61b0f8b4c 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 57ba9fb00..664d1ec35 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 ), @@ -253,7 +253,7 @@ pub fn specs() -> Vec { ok() }), spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| { - let i = req_num("effect.set", p, "index")? as usize; + let i = req_index("effect.set", p, "index")?; let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?; for id in req_targets("effect.set", s, p)? { let n = s.doc_mut()?.get_mut(id)?; @@ -302,7 +302,7 @@ pub fn specs() -> Vec { true, |s, p| { let cmd = "export.update"; - let i = req_num(cmd, p, "index")? as usize; + let i = req_index(cmd, p, "index")?; super::file::export_setting(cmd, ExportSetting::default(), p)?; for id in req_targets(cmd, s, p)? { let n = s.doc_mut()?.get_mut(id)?; @@ -314,7 +314,7 @@ pub fn specs() -> Vec { } ), spec!("export.remove", "Remove export setting", "{ids?, index}", true, |s, p| { - let i = req_num("export.remove", p, "index")? as usize; + let i = req_index("export.remove", p, "index")?; for id in req_targets("export.remove", s, p)? { let n = s.doc_mut()?.get_mut(id)?; if i < n.exports.len() { @@ -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,22 +467,30 @@ 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})) } fn bump_name(n: &str) -> String { if let Some((base, num)) = n.rsplit_once(' ') && let Ok(k) = num.parse::() + && let Some(next) = k.checked_add(1) { - return format!("{base} {}", k + 1); + return format!("{base} {next}"); } format!("{n} 2") } @@ -803,7 +846,8 @@ pub fn opacity_from_digits(digits: &str) -> Option { let mut n = 0u32; let mut count = 0; for c in digits.chars() { - n = n * 10 + c.to_digit(10)?; + // Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps. + n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?; count += 1; } match count { @@ -944,6 +988,16 @@ fn batch_rename(s: &mut Session, p: &Value) -> Result { Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::>()})) } +/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`. +/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets. +fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option { + if needle.is_empty() { + return None; + } + (from..hay.len()) + .find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase())))) +} + fn find_replace(s: &mut Session, p: &Value) -> Result { let find = req_string("edit.findReplace", p, "find")?; if find.is_empty() { @@ -954,11 +1008,16 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { let page = s.page()?; let doc = s.doc_mut()?; let mut hits = Vec::new(); - let lower = find.to_lowercase(); + let needle: Vec = find.chars().collect(); + let r_chars: Vec = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default(); for i in doc.subtree(page) { let Some(n) = doc.node(i) else { continue }; - let text_hit = matches!(&n.kind, NodeKind::Text(t) if t.characters.to_lowercase().contains(&lower)); - let name_hit = names && n.name.to_lowercase().contains(&lower); + let text: Option> = match &n.kind { + NodeKind::Text(t) => Some(t.characters.chars().collect()), + _ => None, + }; + let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some()); + let name_hit = names && find_ci(&n.name.chars().collect::>(), &needle, 0).is_some(); if !(text_hit || name_hit) || n.is_page() { continue; } @@ -966,19 +1025,27 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { if let Some(r) = &replace { let n = doc.get_mut(i)?; if names && name_hit { - n.name = n.name.replace(find.as_str(), r); + let chars: Vec = n.name.chars().collect(); + let mut out = String::new(); + let mut at = 0; + while let Some(pos) = find_ci(&chars, &needle, at) { + out.extend(chars.get(at..pos).unwrap_or_default()); + out.push_str(r); + at = pos + needle.len(); + } + out.extend(chars.get(at..).unwrap_or_default()); + n.name = out; } - if let NodeKind::Text(t) = &mut n.kind { - // Replace occurrence by occurrence so styles shift correctly. - let mut guard = 0; - while let Some(pos) = t.characters.find(find.as_str()) { - let start = t.characters.get(..pos).map(|x| x.chars().count()).unwrap_or(0); - let len = find.chars().count(); - t.replace_range(start, start + len, r); - guard += 1; - if guard > 10_000 || r.contains(find.as_str()) { - break; - } + if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) { + // Replace occurrence by occurrence so styles shift correctly. The search resumes + // after each inserted replacement, so a replacement that matches `find` (in any + // case) neither loops forever nor stops the other occurrences. + let mut cursor = 0; + for _ in 0..10_000 { + let Some(start) = find_ci(&chars, &needle, cursor) else { break }; + t.replace_range(start, start + needle.len(), r); + chars.splice(start..start + needle.len(), r_chars.iter().copied()); + cursor = start + r_chars.len(); } } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 781ed6b07..5043ea402 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -195,7 +195,7 @@ pub fn specs() -> Vec { spec!( "version.autosave", "Autosave version", - "{} save an automatic checkpoint when the document has unsaved changes since the last version (the app does this every 30 minutes); keeps the newest 20 autosaves → {saved, version?}", + "{} save an automatic checkpoint when the document has unsaved changes since the last version (with `pref.autoSave` on: changes made since it was opened); the app does this every 30 minutes; keeps the newest 20 autosaves → {saved, version?}", false, autosave ), @@ -219,7 +219,7 @@ pub fn specs() -> Vec { } ), spec!("doc.switch", "Switch document", "{index}", false, |s, p| { - let i = req_num("doc.switch", p, "index")? as usize; + let i = req_index("doc.switch", p, "index")?; if i >= s.docs.len() { return Err(bad("doc.switch", "no such document")); } @@ -257,8 +257,11 @@ pub const MAX_AUTOSAVES: usize = 20; /// `version.autosave`: a checkpoint of a document with unsaved changes since the last version. fn autosave(s: &mut Session, _: &Value) -> Result { + let auto_save = s.prefs.auto_save; let ds = s.ds_mut()?; - if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() { + // A file that saves itself is rarely unsaved: edits since it was opened count then. + let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty()); + if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() { return Ok(json!({"saved": false})); } Ok(match add_version(ds, AUTOSAVE, "", true) { @@ -437,15 +440,12 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result> { read(path) } +/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one +/// atomic rename (see [`figcraft_format::write_file_atomic`]): a failed or interrupted write never +/// truncates `path`, and no file or symlink at a staging name is ever written through. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> { - let tmp = format!("{path}.tmp-figcraft"); - std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{tmp}: {e}")))?; - std::fs::rename(&tmp, path).map_err(|e| { - // Leave nothing behind when the file can't take its place. - let _ = std::fs::remove_file(&tmp); - EngineError::Other(format!("{path}: {e}")) - }) + figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } #[cfg(target_arch = "wasm32")] @@ -696,10 +696,71 @@ pub fn thumbnail(doc: &Document) -> Option> { /// The `.figcraft` file for a document: content, thumbnail and version history. pub fn file_bytes(ds: &DocState) -> Result> { - let mut doc = (*ds.doc).clone(); - doc.current_page = Some(ds.page); - let thumb = thumbnail(&doc); - figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &ds.versions, &ds.branches).map_err(|e| EngineError::Other(e.to_string())) + FileSnapshot::of(ds).encode() +} + +/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the +/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then +/// [`save`](Self::save) the bytes back on the session's thread. +pub struct FileSnapshot { + uid: u64, + path: Option, + revision: u64, + doc: Arc, + /// The document as last saved when the snapshot was taken: another save since makes it stale. + saved: Arc, + page: NodeId, + versions: Vec, + branches: Vec, +} + +impl FileSnapshot { + pub fn of(ds: &DocState) -> Self { + Self { + uid: ds.uid, + path: ds.path.clone(), + revision: ds.revision, + doc: ds.doc.clone(), + saved: ds.saved_doc.clone(), + page: ds.page, + versions: ds.versions.clone(), + branches: ds.branches.clone(), + } + } + + /// The file's bytes: content, thumbnail and version history. + pub fn encode(&self) -> Result> { + let mut doc = (*self.doc).clone(); + doc.current_page = Some(self.page); + let thumb = thumbnail(&doc); + figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches) + .map_err(|e| EngineError::Other(e.to_string())) + } + + /// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved + /// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document + /// was closed, saved, reverted or given another path since: the bytes are out of date. + pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result { + let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else { + return Ok(false); + }; + write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?; + // The history or branches may have changed since: then they still count as unsaved. + if ds.revision == self.revision { + ds.versions_changed = false; + } + ds.saved_doc = self.doc; + ds.revision += 1; + branch_tabs_saved(s, self.uid); + Ok(true) + } +} + +/// The branch tabs of the file in tab `uid` were saved with it. +fn branch_tabs_saved(s: &mut Session, uid: u64) { + for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { + tab.mark_saved(); + } } fn color_profile(s: &mut Session, p: &Value) -> Result { @@ -724,9 +785,7 @@ fn save_to(s: &mut Session, path: String) -> Result { ds.mark_saved(); ds.revision += 1; let uid = ds.uid; - for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { - tab.mark_saved(); - } + branch_tabs_saved(s, uid); remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } @@ -1327,3 +1386,126 @@ mod rest_import_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod staging_write_tests { + use super::*; + + fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-engine-staging-{name}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). + #[cfg(unix)] + fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::unix::fs::symlink(to, from).is_ok() + } + + #[cfg(windows)] + fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::windows::fs::symlink_file(to, from).is_ok() + } + + #[test] + fn an_existing_staging_file_is_never_written_through() { + let d = dir("existing"); + let path = d.join("shot.svg"); + // The old deterministic staging name, held by an unrelated file: the write must pick its + // own staging name instead of truncating this one. + let old = d.join("shot.svg.tmp-figcraft"); + std::fs::write(&old, b"not yours").unwrap(); + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b""); + assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); + // Just the file and the untouched staging file: no new staging leftovers. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_staging_symlink_is_never_followed() { + let d = dir("symlink"); + let path = d.join("shot.svg"); + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"innocent").unwrap(); + let old = d.join("shot.svg.tmp-figcraft"); + if !symlink(&old, &victim) { + return; // Making a symlink needs privileges on some Windows setups. + } + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); + assert_eq!(std::fs::read(&path).unwrap(), b""); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn concurrent_writers_of_one_file_never_interleave() { + let d = dir("concurrent"); + let path = d.join("shot.svg"); + for round in 0..5u8 { + let mut handles = Vec::new(); + for w in 0u8..4 { + let (path, bytes) = (path.clone(), vec![b'0' + w; 8192]); + handles.push(std::thread::spawn(move || write(&path.to_string_lossy(), &bytes))); + } + let mut wins = 0; + for h in handles { + // On Windows a rename over a file another writer just replaced can lose the race. + if h.join().unwrap().is_ok() { + wins += 1; + } + } + assert!(wins > 0, "round {round}: every writer lost"); + // Whatever landed is one writer's complete payload, never a mix. + let got = std::fs::read(&path).unwrap(); + assert_eq!(got.len(), 8192, "round {round}: a complete payload landed"); + assert!(got.windows(2).all(|w| w[0] == w[1]), "round {round}: one writer's payload, not a mix"); + } + // No staging file was left stuck (a failed writer removes its own). + let left = std::fs::read_dir(&d).unwrap().count(); + assert!(left <= if cfg!(windows) { 5 } else { 1 }, "{left} files left in the staging directory"); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { + let d = dir("failure"); + // The destination is a directory: the rename fails, our staging file is removed and the + // directory is untouched. + let taken = d.join("over-a-directory"); + std::fs::create_dir_all(&taken).unwrap(); + assert!(write(&taken.to_string_lossy(), b"payload").is_err()); + assert!(taken.is_dir()); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); + // A missing parent directory fails without creating anything anywhere. + assert!(write(&d.join("no").join("such.svg").to_string_lossy(), b"payload").is_err()); + assert!(!d.join("no").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // A read-only directory refuses the staging file, and the old destination stays. + let ro = d.join("read-only"); + std::fs::create_dir_all(&ro).unwrap(); + let dest = ro.join("keep.svg"); + std::fs::write(&dest, b"keep").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&ro, perms).unwrap(); + // Running as root would defeat the read-only directory: skip instead of failing. + let probe = ro.join(".probe"); + if std::fs::write(&probe, b"").is_ok() { + let _ = std::fs::remove_file(&probe); + } else { + assert!(write(&dest.to_string_lossy(), b"payload").is_err()); + assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); + assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); + } + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(&ro, perms).unwrap(); + } + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index b2d46988e..0064ffe06 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); @@ -47,7 +52,7 @@ pub fn specs() -> Vec { let id = match id(p, "id") { Some(i) => i, None => { - let k = req_num("page.select", p, "index")? as usize; + let k = req_index("page.select", p, "index")?; *doc.pages.get(k).ok_or_else(|| bad("page.select", "no such page"))? } }; @@ -102,6 +107,9 @@ pub fn specs() -> Vec { let id = id(p, "id").unwrap_or(s.page()?); let k = req_num("page.move", p, "index")?.max(0.0) as usize; let doc = s.doc_mut()?; + if !doc.pages.contains(&id) { + return Err(bad("page.move", format!("{id} is not a page"))); + } doc.pages.retain(|x| *x != id); let k = k.min(doc.pages.len()); doc.pages.insert(k, id); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 257d1a124..dd2daa82a 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st /// Keys `node.set` understands (documented for agents). pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \ -fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ +fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 13693d57f..bed70d3df 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/cmd/view.rs b/crates/engine/src/cmd/view.rs index 6135e5dfe..0f09a6db5 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -334,6 +334,7 @@ pub fn specs() -> Vec { spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)), spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), + spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)), spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| { s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0); s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0); @@ -369,18 +370,24 @@ pub fn specs() -> Vec { Ok(json!({"url": s.prefs.publish_server, "default": crate::live::DEFAULT_SERVER})) } ), - spec!("pref.language", "Interface language", "{language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", false, |s, p| { - let lang = req_string("pref.language", p, "language")?; - let lang = lang.trim(); - // A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system". - let tag = lang.len() <= 35 - && lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric())); - if !tag { - return Err(bad("pref.language", format!("`{lang}` is not a language code"))); + spec!( + "pref.language", + "Interface language", + "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", + false, + |s, p| { + let lang = req_string("pref.language", p, "language")?; + let lang = lang.trim(); + // A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system". + let tag = lang.len() <= 35 + && lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric())); + if !tag { + return Err(bad("pref.language", format!("`{lang}` is not a language code"))); + } + s.prefs.language = lang.to_string(); + Ok(json!({"language": s.prefs.language})) } - s.prefs.language = lang.to_string(); - Ok(json!({"language": s.prefs.language})) - }), + ), spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")), spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")), spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")), diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 39a18ce4a..0975f3fae 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -58,6 +58,9 @@ fn close(a: f64, b: f64) -> bool { /// A square (100,100)–(200,200), selected, and the text "Hello" at (400, 100), not selected. fn scene() -> (Session, NodeId, NodeId) { + // The text is measured with a font: without craft-fonts that is a system one, scanned here + // rather than by whichever test happens to run first (#333). + figcraft_text::scan_system_fonts(); let mut s = Session::new(); let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100}))); let t = id(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 400, "y": 100, "props": {"fontSize": 40}}))); diff --git a/crates/engine/src/dark_page_text_tests.rs b/crates/engine/src/dark_page_text_tests.rs index c4348430f..b46bd0c71 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/double_click_tests.rs b/crates/engine/src/double_click_tests.rs index e51cb7da8..83ea82fdd 100644 --- a/crates/engine/src/double_click_tests.rs +++ b/crates/engine/src/double_click_tests.rs @@ -15,6 +15,9 @@ fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { /// A frame holding a group of a rectangle and a text layer at (60, 60); returns (group, text). fn scene(s: &mut Session) -> (NodeId, NodeId) { + // The text is measured with a font: without craft-fonts that is a system one, scanned here + // rather than by whichever test happens to run first (#333). + figcraft_text::scan_system_fonts(); let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}))["id"]).unwrap(); let r = id_of(&ex(s, "shape.rectangle", json!({"x": 40, "y": 40, "width": 200, "height": 60, "parent": f}))["id"]).unwrap(); let t = id_of(&ex(s, "text.create", json!({"text": "Hi there", "x": 60, "y": 60, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap(); @@ -74,6 +77,26 @@ fn double_clicks_reach_and_edit_text_in_a_group_wherever_they_land() { } } +#[test] +fn editing_text_keeps_other_document_changes_since_open() { + let mut s = Session::new(); + let first = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 80, "height": 40}))["id"]).unwrap(); + let deleted = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 120, "y": 20, "width": 80, "height": 40}))["id"]).unwrap(); + let text = id_of(&ex(&mut s, "text.create", json!({"text": "A", "x": 20, "y": 100}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [first], "props": {"x": 650}})); + ex(&mut s, "edit.delete", json!({"ids": [deleted]})); + + let bounds = s.doc().unwrap().world_bounds(text).unwrap(); + double_click(&mut s, bounds.center().x, bounds.center().y, 0.0, 1.0); + assert_eq!(editing(&s), Some(text)); + crate::textedit::insert(&mut s, "B").unwrap(); + crate::textedit::end(&mut s).unwrap(); + + assert_eq!(s.doc().unwrap().world_bounds(first).unwrap().x0, 650.0); + assert!(!s.doc().unwrap().contains(deleted)); + assert_eq!(s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters, "B"); +} + #[test] fn a_press_drags_only_past_the_threshold_in_screen_pixels() { for (zoom, by, moves) in [(1.0, 2.0, false), (1.0, 4.0, true), (4.0, 0.5, false), (4.0, 1.0, true), (0.25, 8.0, false), (0.25, 16.0, true)] { diff --git a/crates/engine/src/duplicate_spacing_tests.rs b/crates/engine/src/duplicate_spacing_tests.rs new file mode 100644 index 000000000..b65fbb641 --- /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/effects_tests.rs b/crates/engine/src/effects_tests.rs index 5142c40d5..be69fac20 100644 --- a/crates/engine/src/effects_tests.rs +++ b/crates/engine/src/effects_tests.rs @@ -85,3 +85,24 @@ fn inspect_and_css_show_the_new_effects() { let css = code_for(doc, r, CodeLang::Css); assert!(css.contains("backdrop-filter: blur(2px);"), "{css}"); } + +#[test] +fn negative_or_fractional_index_is_refused_not_item_zero() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("effect.add", &json!({"ids": [r], "type": "layerBlur"})).unwrap(); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + let before = s.doc().unwrap().node(r).unwrap().clone(); + assert_eq!(before.exports.len(), 2); + for index in [json!(-1), json!(-0.5), json!(1.5)] { + let set = json!({"ids": [r], "index": index, "effect": {"type": "dropShadow"}}); + assert!(s.execute("effect.set", &set).is_err(), "effect.set index {index}"); + let upd = json!({"ids": [r], "index": index, "format": "svg"}); + assert!(s.execute("export.update", &upd).is_err(), "export.update index {index}"); + assert!(s.execute("export.remove", &json!({"ids": [r], "index": index})).is_err(), "export.remove index {index}"); + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!(n.effects, before.effects, "index {index}"); + assert_eq!(n.exports, before.exports, "index {index}"); + } +} diff --git a/crates/engine/src/gradient_shorthand_tests.rs b/crates/engine/src/gradient_shorthand_tests.rs new file mode 100644 index 000000000..3ea51a898 --- /dev/null +++ b/crates/engine/src/gradient_shorthand_tests.rs @@ -0,0 +1,107 @@ +//! The documented gradient shorthand (`{"type":"linearGradient","stops":["#hex",…]}`) works +//! through every route into the engine, not only MCP (#252): `figcraft-cli run` and the control +//! channel send params straight to `Session::execute`. + +use figcraft_color::Paint; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::{id_of, paint_of}; + +/// The red→blue left-to-right gradient as written in the docs. +fn short() -> Value { + json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]}) +} + +/// The same gradient in the engine's own form. +fn long() -> Value { + json!({"type": "gradient", "kind": "linear", + "stops": [{"position": 0, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}, + {"position": 1, "color": {"r": 0, "g": 0, "b": 1, "a": 1}}], + "from": [0, 0.5], "to": [1, 0.5], "width": [0, 1.5]}) +} + +fn rect(s: &mut Session) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap() +} + +fn fills(s: &Session, id: NodeId) -> Vec { + s.doc().unwrap().node(id).unwrap().fills.clone() +} + +#[test] +fn the_shorthand_reads_as_the_long_form() { + assert_eq!(paint_of(&short()).expect("the documented shorthand is a paint"), paint_of(&long()).unwrap()); +} + +#[test] +fn node_set_takes_the_shorthand() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("node.set", &json!({"ids": [r], "fills": [short()]})).unwrap(); + s.execute("node.set", &json!({"ids": [r], "strokes": [short()]})).unwrap(); + let want = paint_of(&long()).unwrap(); + assert_eq!(fills(&s, r), vec![want.clone()]); + assert_eq!(s.doc().unwrap().node(r).unwrap().strokes, vec![want]); +} + +#[test] +fn node_create_fill_add_and_fill_set_take_the_shorthand() { + let want = paint_of(&long()).unwrap(); + let mut s = Session::new(); + let v = s.execute("node.create", &json!({"type": "rectangle", "width": 200, "height": 100, "props": {"fills": [short()]}})).unwrap(); + let r = id_of(&v["id"]).unwrap(); + assert_eq!(fills(&s, r), vec![want.clone()]); + s.execute("fill.add", &json!({"ids": [r], "paint": short()})).unwrap(); + assert_eq!(fills(&s, r), vec![want.clone(), want.clone()]); + let r2 = rect(&mut s); + s.execute("fill.set", &json!({"ids": [r2], "index": 0, "paint": short()})).unwrap(); + assert_eq!(fills(&s, r2), vec![want]); +} + +#[test] +fn shorthand_defaults_match_the_mcp_server() { + // No handles: top to bottom, with a square width handle. + let p = paint_of(&json!({"type": "linearGradient", "stops": ["#000000", "#ffffff"]})).unwrap(); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!( + (v["kind"].clone(), v["from"].clone(), v["to"].clone(), v["width"].clone()), + (json!("linear"), json!([0.5, 0.0]), json!([0.5, 1.0]), json!([-0.5, 0.0])) + ); + // Plain colour stops are spread evenly. + let p = paint_of(&json!({"type": "radialGradient", "stops": ["#000000", "#808080", "#ffffff"]})).unwrap(); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v["kind"], "radial"); + let pos: Vec = v["stops"].as_array().unwrap().iter().map(|s| s["position"].as_f64().unwrap()).collect(); + assert_eq!(pos, vec![0.0, 0.5, 1.0]); + for kind in ["angular", "diamond"] { + let p = paint_of(&json!({"type": format!("{kind}Gradient"), "stops": ["#000000", "#ffffff"]})).unwrap(); + assert_eq!(serde_json::to_value(&p).unwrap()["kind"], kind); + } + // A CSS colour inside a solid paint or a positioned stop is read too. + assert_eq!(paint_of(&json!({"type": "solid", "color": "#00ff00"})), paint_of(&json!("#00ff00"))); + let p = paint_of(&json!({"type": "gradient", "stops": [{"position": 0, "color": "#ff0000"}, {"position": 1, "color": "#0000ff"}], "from": [0, 0.5], "to": [1, 0.5]})).unwrap(); + assert_eq!(p, paint_of(&long()).unwrap()); +} + +#[test] +fn junk_is_still_refused() { + let mut s = Session::new(); + let r = rect(&mut s); + for junk in [ + json!({"type": "linearGradient", "stops": "red"}), + json!({"type": "linearGradient", "stops": [42]}), + json!({"type": "fooGradient", "stops": ["#ff0000"]}), + json!({"type": "linearGradient", "stops": ["#ff0000"], "from": "left"}), + ] { + let e = s.execute("node.set", &json!({"ids": [r], "fills": [junk.clone()]})).unwrap_err().to_string(); + assert!(e.contains("`fills` must be paints or colours"), "{junk}: {e}"); + } + // Deep nesting doesn't overflow the stack. + let mut deep = json!("#ff0000"); + for _ in 0..200 { + deep = json!({"type": "linearGradient", "stops": [{"position": 0, "color": deep}]}); + } + assert!(paint_of(&deep).is_none()); +} diff --git a/crates/engine/src/interaction_rollback_tests.rs b/crates/engine/src/interaction_rollback_tests.rs new file mode 100644 index 000000000..3a7380170 --- /dev/null +++ b/crates/engine/src/interaction_rollback_tests.rs @@ -0,0 +1,67 @@ +//! A command that fails part-way (a property that applies, then one that doesn't) leaves nothing +//! behind — also during an interaction, whose last valid preview and own snapshot survive it. + +use serde_json::json; + +use crate::Session; +use crate::params::id_of; + +fn rect(s: &mut Session) -> crate::NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 60})).unwrap()["id"]).unwrap() +} + +/// `width` applies (geometry keys go first), then `visible` fails validation. +fn partially_applicable(s: &mut Session, id: crate::NodeId) -> crate::Result { + s.execute("node.set", &json!({"id": id, "width": 123, "visible": "yes"})) +} + +#[test] +fn a_failed_command_during_an_interaction_keeps_the_prior_preview() { + let mut s = Session::new(); + let r = rect(&mut s); + s.begin_interaction("Move").unwrap(); + let props = serde_json::Map::from_iter([("x".to_string(), json!(20.0))]); + s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap(); + let (previewed, selection, page) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.selection.clone(), ds.page) + }; + let before_journal = s.journal.clone(); + let before_history = s.ds().unwrap().history.undo.len(); + + assert!(partially_applicable(&mut s, r).is_err()); + + // The rejected edit is gone: the command-entry state (the valid preview) is back. + let ds = s.ds().unwrap(); + assert_eq!(*ds.doc, *previewed, "the document is what the preview showed"); + assert_eq!(ds.doc.node(r).unwrap().width, 80.0); + assert_eq!(ds.selection, selection); + assert_eq!(ds.page, page); + assert_eq!(ds.history.undo.len(), before_history); + assert_eq!(s.journal, before_journal); + + // The interaction is alive and commits the preview, not the rejected mutation. + s.commit_interaction().unwrap(); + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!((n.width, n.visible), (80.0, true)); + assert_eq!(n.transform.as_coeffs()[4], 20.0, "the previewed move is committed"); + assert_eq!(s.undo_depth(), (before_history + 1, 0)); + assert_eq!(s.undo_labels(1), vec!["Move".to_string()]); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().node(r).unwrap().transform.as_coeffs()[4], 0.0); +} + +#[test] +fn a_failed_command_without_an_interaction_still_rolls_back() { + let mut s = Session::new(); + let r = rect(&mut s); + let before_journal = s.journal.clone(); + let before_history = s.ds().unwrap().history.undo.len(); + + assert!(partially_applicable(&mut s, r).is_err()); + + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!((n.width, n.visible), (80.0, true)); + assert_eq!(s.ds().unwrap().history.undo.len(), before_history); + assert_eq!(s.journal, before_journal); +} diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs index 8f6f28c18..38775d3b4 100644 --- a/crates/engine/src/keymap_tests.rs +++ b/crates/engine/src/keymap_tests.rs @@ -4,11 +4,11 @@ use serde_json::{Value, json}; -use crate::Session; use crate::cmd::edit::opacity_from_digits; use crate::keymap::{Context, GESTURES, Group, KEYS, Mods, Platform, display, lookup, matches, normalize, parse, primary}; use crate::params::id_of; use crate::tools::ToolId; +use crate::{EngineError, Session}; use figcraft_doc::NodeId; const PLATFORMS: [Platform; 2] = [Platform::Mac, Platform::Other]; @@ -186,6 +186,7 @@ fn opacity_digits() { assert_eq!(opacity_from_digits("00"), Some(0.0)); assert_eq!(opacity_from_digits(""), None); assert_eq!(opacity_from_digits("123"), None); + assert_eq!(opacity_from_digits("9999999999"), None, "ten digits must not overflow u32"); assert_eq!(opacity_from_digits("4x"), None); assert_eq!(opacity_from_digits("٣"), None, "ASCII digits only"); @@ -201,6 +202,10 @@ fn opacity_digits() { ex(&mut s, "edit.undo", json!({})); assert_eq!(op(&s, a), 0.3, "one undo step"); assert!(s.execute("object.opacityKeys", &json!({"digits": "123"})).is_err()); + assert!( + matches!(s.execute("object.opacityKeys", &json!({"digits": "9999999999"})), Err(EngineError::BadParams { .. })), + "an overflowing digit string is a parameter error, not a panic" + ); assert!(s.execute("object.opacityKeys", &json!({})).is_err()); ex(&mut s, "select.none", json!({})); assert!(s.execute("object.opacityKeys", &json!({"digits": "1"})).is_err(), "nothing selected"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a906..959a6b213 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -107,6 +107,47 @@ pub fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() } } +/// Deepest params nesting `resolve_step_refs` walks before giving up. +const STEP_REF_MAX_DEPTH: usize = 64; + +/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default +/// `id`) of `results[n]`, the result of an earlier batch step. +fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result { + if depth > STEP_REF_MAX_DEPTH { + return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels"))); + } + match v { + Value::Object(o) if o.contains_key("$step") => { + if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") { + return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`"))); + } + let n = o + .get("$step") + .and_then(Value::as_u64) + .ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?; + let field = match o.get("field") { + None => "id", + Some(Value::String(f)) => f.as_str(), + Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))), + }; + let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else { + let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) }; + return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})"))); + }; + result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`"))) + } + Value::Object(o) => { + let mut out = serde_json::Map::with_capacity(o.len()); + for (k, x) in o { + out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?); + } + Ok(Value::Object(out)) + } + Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::>>().map(Value::Array), + _ => Ok(v.clone()), + } +} + /// One undo step. #[derive(Clone, Debug)] pub struct HistoryEntry { @@ -328,6 +369,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 +427,7 @@ impl DocState { focus: None, guide: None, rotation_origin: None, + last_duplicate: None, }; st.zoom_to_fit(); st @@ -538,6 +583,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 +601,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). @@ -564,6 +614,9 @@ pub struct Prefs { /// Reduce motion: the interface's own animations are off (the system's setting turns them off /// too). Prototype transitions still play. pub reduce_motion: bool, + /// Files that have a path are saved by themselves a few seconds after they change (the app's + /// timer; off: only File ▸ Save writes them). + pub auto_save: bool, /// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed /// builds, off for development builds. pub invite_links: Option, @@ -611,11 +664,12 @@ 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(), reduce_motion: false, + auto_save: false, invite_links: None, plugins: Vec::new(), plugin_settings: BTreeMap::new(), @@ -663,8 +717,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 +775,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 } } @@ -837,10 +891,11 @@ impl Session { } } Err(_) => { - // Roll back any partial edit. + // Roll back any partial edit — also during an interaction, where `before` is the + // interaction's last valid preview: restore it and leave the gesture itself (its + // own snapshot) alone. if let Some((doc, sel, page, uid)) = before && let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) - && ds.interaction.is_none() { ds.doc = doc; ds.selection = sel; @@ -1071,9 +1126,20 @@ impl Session { self.ui_requests.push(UiRequest { kind: kind.to_string(), data }); } - /// Run several commands as one undo step. + /// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id` + /// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field), + /// so a step can target a layer that an earlier step of the same batch created. Any object + /// with a `$step` key in a step's params is such a reference, except inside a nested + /// `command.batch` step: its references are to its own steps. pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result> { - self.grouped(label, |s| cmds.iter().map(|(id, p)| s.execute(id, p)).collect()) + self.grouped(label, |s| { + let mut results: Vec = Vec::with_capacity(cmds.len()); + for (step, (id, p)) in cmds.iter().enumerate() { + let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? }; + results.push(s.execute(id, &p)?); + } + Ok(results) + }) } /// Run `f` as one undo step called `label`: the commands it executes push no steps of their @@ -1082,6 +1148,8 @@ impl Session { self.ds()?; let before: Vec<_> = self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect(); + // An edit inside clears the redo steps; a failed group has to bring them back. + let redo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.redo.clone()).collect(); let active = self.active; let journal_len = self.journal.len(); @@ -1099,8 +1167,9 @@ impl Session { if result.is_err() { self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid)); - for (uid, doc, selection, page, undo_len, limit) in &before { + for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) { if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) { + ds.history.redo = redo; ds.doc = doc.clone(); ds.selection = selection.clone(); ds.page = *page; @@ -1393,6 +1462,8 @@ mod autosave_tests; #[cfg(test)] mod batch_rollback_tests; #[cfg(test)] +mod batch_step_ref_tests; +#[cfg(test)] mod binding_lists_tests; #[cfg(test)] mod booleans_tests; @@ -1441,6 +1512,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; @@ -1467,6 +1540,8 @@ mod fx_behaviour_tests; #[cfg(test)] mod gesture_preview_tests; #[cfg(test)] +mod gradient_shorthand_tests; +#[cfg(test)] mod hide_publishing_tests; #[cfg(test)] mod history_behaviour_tests; @@ -1477,6 +1552,8 @@ mod import_export_tests; #[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] +mod interaction_rollback_tests; +#[cfg(test)] mod invite_links_tests; #[cfg(test)] mod keymap_tests; @@ -1517,6 +1594,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/live.rs b/crates/engine/src/live.rs index 09c5831d1..4001b1563 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -517,6 +517,11 @@ pub trait LiveLink: Send { fn background(&mut self, _job: u64, _req: NetRequest) -> bool { false } + /// Remove client `client` from session `session`, which this link hosts: they are + /// disconnected and can't join again from their address while it runs. Returns at once. + fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> { + Err("removing people needs the desktop app or the MCP server".into()) + } /// End session `session` (stop hosting or leave); the others go on. Nothing for a session /// that is over already. fn leave(&mut self, session: SessionId); @@ -1889,6 +1894,59 @@ fn follow(s: &mut Session, p: &Value) -> Result { Ok(json!({"follow": s.live().follow})) } +/// Remove someone from the open file's session: in a session this app hosts, the hub drops them; +/// in a file of yours on a server, the server does (with the file's owner token). They are told +/// the session is over for them, keep their copy, and can't join again from their address while +/// the session (or the server) runs. +fn remove_person(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "collab.removePerson"; + let client = + get(p, "client").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(CMD, "`client` must be a client id"))?; + let live = s.live(); + if live.status != Status::Live { + return Err(bad(CMD, "the open file isn't in a live session")); + } + let name = + live.peers.get(&client).map(|p| p.name.clone()).ok_or_else(|| bad(CMD, format!("nobody with client id {client} is in the session")))?; + let (id, role, server_file) = (live.id, live.role(), live.server_file()); + match (role, server_file) { + (Some(Role::Host), _) => { + let link = s.live.link.as_deref_mut().ok_or_else(|| unavailable(CMD))?; + link.remove(id, client).map_err(EngineError::Other)?; + } + (Some(Role::Guest), Some((server, file))) => { + let owner = + s.live().published.as_ref().and_then(|p| p.owner.clone()).or_else(|| s.online_owner(&server, &file)).filter(|o| !o.is_empty()); + let owner = owner.ok_or_else(|| bad(CMD, "only the file's owner can remove people from it"))?; + let req = Some(Admin::RemovePeer { file: file.clone(), client }); + match hosting_request(s, CMD, &server, &file, &owner, req) { + Ok((_, Some(AdminReply::Done))) => {} + Ok(_) => return Err(EngineError::Other(format!("couldn't remove {name}: the server answered something else"))), + // A server older than this request can't read it. + Err(e) => return Err(EngineError::Other(format!("couldn't remove {name}: {e} (the server may be older than removing people)"))), + } + } + _ => return Err(bad(CMD, "only the person hosting the session, or the file's owner, can remove people")), + } + s.message = Some(format!("{name} was removed from the session")); + Ok(json!({"removed": client, "name": name})) +} + +/// You may remove others from the open file's session: you host it, or it is your file on a +/// server. +pub fn can_remove_people(s: &Session) -> bool { + let live = s.live(); + live.status == Status::Live + && match live.role() { + Some(Role::Host) => true, + Some(Role::Guest) => live.server_file().is_some_and(|(server, file)| { + live.published.as_ref().and_then(|p| p.owner.as_deref()).is_some_and(|o| !o.is_empty()) + || s.online_owner(&server, &file).is_some_and(|o| !o.is_empty()) + }), + None => false, + } +} + fn cursor(s: &mut Session, p: &Value) -> Result { let at = match (num(p, "x"), num(p, "y")) { (Some(x), Some(y)) => Some((x, y)), @@ -1997,6 +2055,13 @@ pub fn specs() -> Vec { |s, _| Ok(s.live_summary()) ), spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow), + spec!( + "collab.removePerson", + "Remove from session", + "{client (a peer's id)} → {removed, name}: they are disconnected (they keep their copy) and can't join again from their address while the session runs; only the person hosting, or the owner of a file on a server, can do it", + false, + remove_person + ), spec!( "collab.cursor", "Show cursor at", diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index 6b05199b3..cacc4f378 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -28,6 +28,8 @@ pub(crate) struct Script { /// The session each presence in `sent` and each message in `msgs` was for. pub(crate) sent_to: Vec, pub(crate) msgs_to: Vec, + /// The people removed: `(session, client)`. + pub(crate) removed: Vec<(u64, u32)>, } pub(crate) struct Mock(pub(crate) Arc>); @@ -62,6 +64,10 @@ impl LiveLink for Mock { s.published.push(opts); Ok(()) } + fn remove(&mut self, session: u64, client: u32) -> Result<(), String> { + self.0.lock().unwrap().removed.push((session, client)); + Ok(()) + } fn leave(&mut self, session: u64) { let mut s = self.0.lock().unwrap(); s.left += 1; @@ -314,6 +320,43 @@ fn the_host_ending_resets_everything() { assert_eq!(s.message.as_deref(), Some("The host ended the session")); } +#[test] +fn the_host_removes_people_and_guests_cant() { + let (mut s, script) = session(); + let remove = |s: &mut Session, client: serde_json::Value| s.execute("collab.removePerson", &json!({ "client": client })); + // Not in a session: nobody to remove. + assert!(matches!(remove(&mut s, json!(2)), Err(EngineError::BadParams { .. }))); + assert!(!crate::live::can_remove_people(&s)); + s.execute("collab.host", &json!({})).unwrap(); + let id = s.live().id; + push(&script, vec![Event::PeerJoined(peer(2, "Ada")), Event::PeerJoined(peer(3, "Bo"))]); + s.live_poll(); + assert!(crate::live::can_remove_people(&s)); + // Only someone who is here, by a client id. + for bad in [json!(9), json!(1), json!(-2), json!("2"), json!(4_294_967_296u64)] { + assert!(matches!(remove(&mut s, bad.clone()), Err(EngineError::BadParams { .. })), "{bad}"); + } + let r = remove(&mut s, json!(2)).unwrap(); + assert_eq!((r["removed"].as_u64(), r["name"].as_str()), (Some(2), Some("Ada"))); + assert_eq!(script.lock().unwrap().removed, [(id, 2)]); + assert_eq!(s.message.as_deref(), Some("Ada was removed from the session")); + // The host's own session tells them she left. + push(&script, vec![Event::PeerLeft(2)]); + s.live_poll(); + assert_eq!(s.live().peers.keys().copied().collect::>(), [3]); + s.execute("collab.stop", &json!({})).unwrap(); + + // A guest in someone else's session can't remove anyone. + let (mut s, script) = session(); + s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); + push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana"), peer(3, "Bo")] }]); + s.live_poll(); + assert!(!crate::live::can_remove_people(&s)); + let e = remove(&mut s, json!(3)).unwrap_err().to_string(); + assert!(e.contains("only the person hosting"), "{e}"); + assert!(script.lock().unwrap().removed.is_empty()); +} + #[test] fn collab_commands_survive_junk() { let junk = [ diff --git a/crates/engine/src/measure_tests.rs b/crates/engine/src/measure_tests.rs index 2329e7be7..539386bd7 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/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index f28024615..3866d5925 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -337,6 +337,34 @@ fn pages_by_the_documented_rules() { assert_ne!(s.page().unwrap(), div, "deleting the open page opens a page, never a divider"); } +#[test] +fn page_move_refuses_what_is_not_a_page() { + let mut s = Session::new(); + let r = rect(&mut s, "r", 30.0, 40.0, None); + let before = pages(&mut s); + assert!(s.execute("page.move", &json!({"id": r, "index": 0})).is_err(), "a layer id is not a page"); + assert!(s.execute("page.move", &json!({"id": 999999, "index": 0})).is_err(), "a page that isn't there is not a page"); + assert_eq!(pages(&mut s), before, "the page list is untouched"); + assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out"); +} + +#[test] +fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() { + let mut s = Session::new(); + let p2 = id(&ex(&mut s, "page.add", json!({}))); + for bad_index in [json!(-1), json!(0.5)] { + assert!(s.execute("page.select", &json!({"index": bad_index})).is_err(), "page.select index {bad_index} must fail"); + assert_eq!(s.page().unwrap(), p2, "page.select index {bad_index} leaves the open page alone"); + } + ex(&mut s, "file.new", json!({})); + let active = s.active; + assert_eq!(active, 1); + for bad_index in [json!(-1), json!(0.5)] { + assert!(s.execute("doc.switch", &json!({"index": bad_index})).is_err(), "doc.switch index {bad_index} must fail"); + assert_eq!(s.active, active, "doc.switch index {bad_index} leaves the active tab alone"); + } +} + #[test] fn assets_panel_lists_files_and_drops_by_the_documented_rules() { let mut s = Session::new(); @@ -386,3 +414,14 @@ fn assets_panel_lists_files_and_drops_by_the_documented_rules() { assert_eq!(kids(&s, row).first(), Some(&into), "an asset dropped in an auto layout frame joins the flow where it is dropped"); assert_eq!(s.doc().unwrap().page_of(into), Some(page)); } + +#[test] +fn duplicating_a_layer_named_past_the_counter_does_not_overflow() { + let mut s = Session::new(); + ex(&mut s, "pref.renameDuplicates", json!({"on": true})); + let r = rect(&mut s, "r", 30.0, 40.0, None); + ex(&mut s, "node.set", json!({"id": r, "props": {"name": "Layer 18446744073709551615"}})); + let dup = id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [r]}))["ids"][0]).unwrap(); + assert_eq!(name(&s, dup), "Layer 18446744073709551615 2"); + assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out"); +} diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index 4eca1444b..bbaa42a45 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -3,7 +3,7 @@ use figcraft_color::{BlendMode, Color, Paint}; use figcraft_doc::NodeId; -use serde_json::Value; +use serde_json::{Value, json}; use crate::{EngineError, Result, Session, bad}; @@ -135,12 +135,80 @@ pub fn color(p: &Value, key: &str) -> Option { get(p, key).and_then(color_of) } -/// A paint from JSON: a colour string/object (solid) or a full paint object. +/// Read the friendlier paint/effect JSON that the docs and MCP tools describe as the engine's own +/// form: any `"color"` given as a CSS string ("#RRGGBB[AA]", "rgb(…)") becomes {r,g,b,a}, +/// `{"type":"linearGradient"}` becomes `{"type":"gradient","kind":"linear"}`, gradients get default +/// handles (top to bottom) when `from`/`to`/`width` are missing, and stops given as plain colours +/// are spread evenly. Anything else is left for serde to refuse. +pub fn normalize_paint(v: &mut Value) { + normalize_paintish(v, 0); +} + +fn normalize_paintish(v: &mut Value, depth: usize) { + if depth > 16 { + return; + } + match v { + Value::Array(a) => { + for x in a { + normalize_paintish(x, depth + 1); + } + } + Value::Object(o) => { + if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(color_of) { + o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null)); + } + let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient")); + if is_gradient { + // Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}. + if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase) + && t != "gradient" + { + let kind = t.trim_end_matches("gradient").to_string(); + o.insert("type".into(), json!("gradient")); + o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind })); + } + o.entry("kind").or_insert(json!("linear")); + let linear = o.get("kind").and_then(Value::as_str) == Some("linear"); + let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) }; + o.entry("from").or_insert(json!(from)); + o.entry("to").or_insert(json!(to)); + // A linear gradient's width handle goes square to its axis (else its bands skew, + // or vanish when the handle lies on the axis). + let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + if linear + && !o.contains_key("width") + && let (Some(f), Some(t)) = (pt("from"), pt("to")) + { + o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)])); + } + o.entry("width").or_insert(json!(width)); + if let Some(Value::Array(stops)) = o.get_mut("stops") { + let n = stops.len(); + for (i, s) in stops.iter_mut().enumerate() { + if let Some(c) = color_of(s).filter(|_| s.is_string()) { + let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 }; + *s = json!({"position": pos, "color": c}); + } else { + normalize_paintish(s, depth + 1); + } + } + } + } + } + _ => {} + } +} + +/// A paint from JSON: a colour string/object (solid), a full paint object or its shorthand +/// ([`normalize_paint`]). pub fn paint_of(v: &Value) -> Option { if let Some(c) = color_of(v) { return Some(Paint::solid(c)); } - let mut p: Paint = serde_json::from_value(v.clone()).ok()?; + let mut v = v.clone(); + normalize_paint(&mut v); + let mut p: Paint = serde_json::from_value(v).ok()?; p.opacity = if p.opacity.is_finite() { p.opacity.clamp(0.0, 1.0) } else { 1.0 }; Some(p) } diff --git a/crates/engine/src/pen_overlay_tests.rs b/crates/engine/src/pen_overlay_tests.rs new file mode 100644 index 000000000..7e0064d2b --- /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 e97cae044..154c5ae19 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 82474df00..b68f1fe95 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 40da2f1df..dbf432d41 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -161,6 +161,28 @@ fn text_create_autosize_and_edit() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "Hello"); } +#[test] +fn find_replace_replaces_every_occurrence_when_replacement_contains_find() { + for (text, find, replace, want) in [ + ("cat and cat", "cat", "cats", "cats and cats"), + ("cat and cat", "cat", "category", "category and category"), + ("banana", "a", "aa", "baanaanaa"), + ] { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": text, "x": 0, "y": 0})).unwrap()); + s.execute("edit.findReplace", &json!({"find": find, "replace": replace})).unwrap(); + assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, want, "{text}: {find} -> {replace}"); + } +} + +#[test] +fn find_replace_rejects_empty_find() { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": "cat", "x": 0, "y": 0})).unwrap()); + assert!(s.execute("edit.findReplace", &json!({"find": "", "replace": "x"})).is_err()); + assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "cat"); +} + #[test] fn components_and_instances_sync() { let mut s = sess(); @@ -253,6 +275,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(); @@ -532,3 +597,27 @@ fn guides_add_move_remove() { s.execute("guide.clear", &json!({})).unwrap(); assert!(guides(&s).is_empty()); } + +#[test] +fn find_replace_matches_and_replaces_case_insensitively() { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap()); + let u = id(&s.execute("text.create", &json!({"text": "İ HELLO hello", "x": 0, "y": 40})).unwrap()); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "HELLO box"})).unwrap()); + let chars = |s: &Session, i| s.doc().unwrap().node(i).unwrap().kind.text().unwrap().characters.clone(); + let r = s.execute("edit.findReplace", &json!({"find": "hello", "replace": "bye", "layerNames": true})).unwrap(); + assert_eq!(r["matches"], json!([t, u, f])); + assert_eq!(chars(&s, t), "bye"); + assert_eq!(chars(&s, u), "İ bye bye"); + assert_eq!(s.doc().unwrap().node(f).unwrap().name, "bye box"); +} + +#[test] +fn find_replace_changing_only_the_case_replaces_every_occurrence() { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": "figcraft and FIGCRAFT, figcraft", "x": 0, "y": 0})).unwrap()); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "figcraft / figcraft"})).unwrap()); + s.execute("edit.findReplace", &json!({"find": "figcraft", "replace": "FigCraft", "layerNames": true})).unwrap(); + assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); + assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); +} diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 51550f7e4..84315f1fb 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -192,3 +192,40 @@ fn paragraph_indent_only_moves_left_aligned_text() { let (x, w2) = first_x(&s); assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it"); } + +/// A new text made with the Text tool by a press at `from`, a drag to `to` and a release, holding +/// `chars`: its box and resize mode. +fn text_tool(from: (f64, f64), to: (f64, f64), chars: &str) -> (figcraft_geom::Rect, figcraft_doc::TextAutoResize) { + use crate::tools::{PointerEvent, PointerKind, ToolId}; + let mut s = Session::new(); + s.set_tool(ToolId::Text); + s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap(); + crate::textedit::insert(&mut s, chars).unwrap(); + let t = s.ds().unwrap().text_edit.as_ref().unwrap().id; + let doc = s.doc().unwrap(); + let NodeKind::Text(td) = &doc.node(t).unwrap().kind else { panic!("not a text") }; + (doc.world_bounds(t).unwrap(), td.auto_resize) +} + +#[test] +fn a_drag_with_the_text_tool_makes_a_fixed_size_box() { + // #360: the drag's size, its top-left where the drag started, and its text wraps in it; a + // click, or a drag within a few pixels, still makes auto-width text. + use figcraft_doc::TextAutoResize; + figcraft_text::scan_system_fonts(); + let long = "A sentence much longer than eighty pixels"; + let (line, auto) = text_tool((100.0, 100.0), (100.0, 100.0), long); + assert_eq!(auto, TextAutoResize::WidthAndHeight); + assert_eq!(text_tool((100.0, 100.0), (101.0, 101.0), long).1, TextAutoResize::WidthAndHeight, "a slip of the hand is a click"); + let (b, auto) = text_tool((100.0, 100.0), (180.0, 400.0), long); + assert_eq!(auto, TextAutoResize::None); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (100.0, 100.0, 80.0, 300.0), "the text wraps in a box that keeps its size"); + // Dragged up and to the left, the box is the same. + let (b2, _) = text_tool((180.0, 400.0), (100.0, 100.0), long); + assert_eq!((b2.x0, b2.y0, b2.width(), b2.height()), (100.0, 100.0, 80.0, 300.0)); + // A flat drag still makes a box one line tall. + let (b3, _) = text_tool((100.0, 100.0), (180.0, 101.0), "A"); + assert_eq!((b3.width(), b3.height()), (80.0, line.height())); +} diff --git a/crates/engine/src/theme_pages_tests.rs b/crates/engine/src/theme_pages_tests.rs index e4c1071ae..feaf4c6de 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 cfb9176d0..733fc08c2 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). @@ -370,6 +376,13 @@ pub(crate) enum Gesture { TextSelect { id: NodeId, }, + /// A new text from the Text tool, still pressed: a drag makes it a box the size of the drag + /// (at least `line`, the height of its first line, tall). + TextBox { + id: NodeId, + start: Point, + line: f64, + }, PenDrag { anchor: Point, }, @@ -397,6 +410,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 +551,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 +642,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 +722,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 +1030,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 +1078,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 +1143,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 +1218,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 }; @@ -1347,6 +1407,11 @@ impl Session { self.tool.gesture = g; return self.draw_drag(ev, start, id, parent, &targets); } + Gesture::TextBox { id, start, line } => { + let (id, start, line) = (*id, *start, *line); + self.tool.gesture = g; + return self.text_box_drag(ev, id, start, line); + } Gesture::Arc { id, handle, arc } => { let (id, handle, arc) = (*id, *handle, *arc); self.tool.gesture = g; @@ -1675,6 +1740,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 +1769,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()?; @@ -1760,7 +1830,7 @@ impl Session { self.tool.overlay.pen.clear(); self.pencil_finish(&points, &pressure)?; } - Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {} + Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} Gesture::Vector(_) => self.commit_interaction()?, } @@ -1878,22 +1948,46 @@ 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) { n.width = w.max(1.0); n.height = h; } + let line = n.height; d.insert(n, parent, None)?; ds.selection = vec![id]; textedit::begin(self, id, Some(0), true)?; + self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line }; if !self.prefs.keep_tool { self.tool.tool = ToolId::Move; } Ok(()) } + /// Drag out a new text box: past the drag slop it has the drag's size (at least one line + /// tall) and its top-left, and that size is fixed. + fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { + if within_slop(ev, start) { + return Ok(()); + } + let r = Rect::from_points(start, ev.p()); + let d = self.ds_mut()?.doc_mut(); + let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default(); + let n = d.get_mut(id)?; + n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round())); + n.width = r.width().round().max(1.0); + n.height = r.height().round().max(line); + n.child_layout.sizing_h = Sizing::Fixed; + n.child_layout.sizing_v = Sizing::Fixed; + if let NodeKind::Text(t) = &mut n.kind { + t.auto_resize = TextAutoResize::None; + } + self.ds_mut()?.revision += 1; + Ok(()) + } + fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let doc = ds.doc.clone(); @@ -1931,6 +2025,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 +2082,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 +2165,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 3fbecb017..47270a42d 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/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 787ca6d4b..e0642f32a 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -25,6 +25,8 @@ const MAX_DEPTH: usize = 256; const MAX_COMMANDS: usize = 200_000; /// Id of "no style". const NO_STYLE: &str = "4294967295:4294967295"; +/// Longest asset `key` or `version` kept for resolving references given as assets. +const MAX_ASSET_PART: usize = 256; /// The REST API file JSON for message `msg`, with the warnings about what was skipped. pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { @@ -86,6 +88,10 @@ fn vec2(v: &Kv) -> Option { Some(json!({"x": num(v, "x")?, "y": num(v, "y")?})) } +fn size(v: &Kv) -> Option<(f64, f64)> { + Some((num(v, "x")?, num(v, "y")?)) +} + fn matrix(m: &Kv) -> Option { let c = |k: &str| num(m, k); let a = Affine::new([c("m00")?, c("m10")?, c("m01")?, c("m11")?, c("m02")?, c("m12")?]); @@ -113,11 +119,21 @@ fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) { #[derive(Clone)] struct View<'a> { layers: Vec<&'a Kv<'a>>, + /// Size and transform worked out from the constraints, over the layers' own. + fitted: Option<((f64, f64), Affine)>, } impl<'a> View<'a> { fn of(n: &'a Kv<'a>) -> Self { - Self { layers: vec![n] } + Self { layers: vec![n], fitted: None } + } + + fn size(&self) -> Option<(f64, f64)> { + self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(size)) + } + + fn transform(&self) -> Option { + self.fitted.map(|f| f.1).or_else(|| self.get("transform").and_then(matrix)) } fn with(&self, top: impl IntoIterator>) -> Self { @@ -147,13 +163,83 @@ impl<'a> View<'a> { } } +/// How the layers of a resized container follow it when the file gives no derived layout for +/// them: a frame's by their constraints, from its main's size to its own; a group's scale with it. +#[derive(Clone, Copy)] +enum Fit { + Frame { from: (f64, f64), to: (f64, f64) }, + Scale(f64, f64), +} + +impl Fit { + fn frame(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option { + let (from, to) = (from?, to?); + ((from.0 - to.0).abs() > 1e-9 || (from.1 - to.1).abs() > 1e-9).then_some(Fit::Frame { from, to }) + } + + fn scale(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option { + let (from, to) = (from?, to?); + let k = |a: f64, b: f64| if a > 1e-9 { b / a } else { 1.0 }; + let (kx, ky) = (k(from.0, to.0), k(from.1, to.1)); + ((kx - 1.0).abs() > 1e-9 || (ky - 1.0).abs() > 1e-9).then_some(Fit::Scale(kx, ky)) + } + + /// The size and transform (in the parent's coordinates) of a `w` × `h` layer at `t` with + /// constraints `h_con`, `v_con`. Like the editor's constraint solver, a layer keeps its + /// rotation and flip and stretches along its own axes. + fn apply(self, w: f64, h: f64, t: Affine, h_con: Option<&str>, v_con: Option<&str>) -> ((f64, f64), Affine) { + let [a, b, c, d, _, _] = t.as_coeffs(); + let lin = Affine::new([a, b, c, d, 0.0, 0.0]); + let bb = figcraft_doc::geom::transform_rect_bbox(t, figcraft_doc::geom::Rect::new(0.0, 0.0, w, h)); + // Where the new box goes: its top-left (constraints) or its centre (a group's scale). + let (kx, ky, x0, y0, centred) = match self { + Fit::Frame { from, to } => { + let axis = |con: Option<&str>, lo: f64, hi: f64, old: f64, new: f64| match con { + Some("MAX" | "FIXED_MAX") => (lo + new - old, hi + new - old), + Some("STRETCH") => (lo, hi + new - old), + Some("CENTER") => (lo + (new - old) / 2.0, hi + (new - old) / 2.0), + Some("SCALE") if old > 1e-9 => (lo * new / old, hi * new / old), + _ => (lo, hi), + }; + let (x0, x1) = axis(h_con, bb.x0, bb.x1, from.0, to.0); + let (y0, y1) = axis(v_con, bb.y0, bb.y1, from.1, to.1); + let k = |new: f64, old: f64| if old > 1e-9 { (new / old).max(0.0) } else { 1.0 }; + (k(x1 - x0, bb.width()), k(y1 - y0, bb.height()), x0, y0, false) + } + Fit::Scale(kx, ky) => (kx, ky, bb.center().x * kx, bb.center().y * ky, true), + }; + let along = |x: f64, y: f64, fallback: f64| { + let len = x.hypot(y); + let k = (kx * x).hypot(ky * y) / len; + if len > 1e-12 && k.is_finite() { k } else { fallback } + }; + let size = (w * along(a, b, kx), h * along(c, d, ky)); + let nb = figcraft_doc::geom::transform_rect_bbox(lin, figcraft_doc::geom::Rect::new(0.0, 0.0, size.0, size.1)); + let at = if centred { nb.center() } else { figcraft_doc::geom::Point::new(nb.x0, nb.y0) }; + (size, Affine::translate((x0 - at.x, y0 - at.y)) * lin) + } +} + +/// Overrides and derived layout by id path. +type Overrides<'a> = BTreeMap, Vec<(Entry, &'a Kv<'a>)>>; + +/// Where an override entry comes from. +#[derive(Clone, Copy, PartialEq)] +enum Entry { + Override, + Derived, + /// Derived layout of a nested instance for a size it no longer has: its size and place no + /// longer hold, the rest (resolved colours, text) still does. + Stale, +} + /// Where an instance's expanded layers are: the overrides and derived layout of the outermost /// instance (by id path), the path of the instance being expanded, and the property values. #[derive(Clone)] struct Inst<'a> { root: String, path: Vec, - overrides: Rc, Vec<&'a Kv<'a>>>>, + overrides: Rc>, props: Rc>>, /// Mains being expanded (an instance inside its own main stops there). symbols: Vec, @@ -165,6 +251,10 @@ struct Mapper<'a> { blobs: Vec<&'a [u8]>, /// Component property definitions: id → (REST name "Name#id", type). prop_defs: BTreeMap, + /// File nodes by asset `key` and `version`: references can name a collection, variable or + /// style as the asset instead of the guid, and the file carries the resources to resolve + /// them (the collection node itself says the same `key`). + assets: BTreeMap>, styles: Obj, /// Descriptions of components and component sets (REST `components` / `componentSets`). components: Obj, @@ -203,7 +293,37 @@ impl<'a> Mapper<'a> { } let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default().iter().map(|b| b.get("bytes").and_then(Kv::bytes).unwrap_or_default()).collect(); - Self { nodes, kids, blobs, prop_defs, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 } + let mut assets: BTreeMap> = BTreeMap::new(); + for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() { + let (Some(key), Some(id)) = (n.get("key").and_then(Kv::str), n.get("guid").and_then(guid)) else { continue }; + if key.is_empty() || key.len() > MAX_ASSET_PART { + continue; + } + let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or(""); + assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id); + } + Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 } + } + + /// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`, + /// else the file node its asset `key` and `version` names (a collection from a library is + /// referenced as the asset, and the file's own node for it says the same `key`). `None` when + /// the reference has neither or the asset names nothing (or ambiguously) in the file. + /// Original code for this importer: the field names are those of the schema each file embeds. + fn resource_id(&self, v: &Kv) -> Option { + if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE) { + return Some(g); + } + let asset = v.get("assetRef")?; + let key = asset.get("key").and_then(Kv::str)?; + let versions = self.assets.get(key)?; + let version = asset.get("version").and_then(Kv::str); + match version { + Some(v) => versions.get(v), + // Without a version: an unversioned record, else the file's only one. + None => versions.get("").or_else(|| (versions.len() == 1).then(|| versions.values().next()).flatten()), + } + .cloned() } fn warn(&mut self, w: impl Into) { @@ -263,7 +383,7 @@ impl<'a> Mapper<'a> { let name = sp.get("name").and_then(Kv::str).filter(|s| !s.is_empty()).unwrap_or("Flow"); flows.push((sp.get("position").and_then(Kv::str).unwrap_or(""), json!({"nodeId": c, "name": name}))); } - kids.extend(self.layer(&c, None, Affine::IDENTITY, None, 0)); + kids.extend(self.layer(&c, None, Affine::IDENTITY, None, None, 0)); } flows.sort_by(|a, b| a.0.cmp(b.0)); if !flows.is_empty() { @@ -276,7 +396,15 @@ impl<'a> Mapper<'a> { /// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the /// parent's coordinates to the enclosing frame's (groups and booleans place their children in /// the frame's space, like the REST JSON). `parent`: the parent's view (grid tracks). - fn layer(&mut self, id: &str, inst: Option<&Inst<'a>>, space: Affine, parent: Option<&View<'a>>, depth: usize) -> Option { + fn layer( + &mut self, + id: &str, + inst: Option<&Inst<'a>>, + space: Affine, + parent: Option<&View<'a>>, + fit: Option, + depth: usize, + ) -> Option { if depth > MAX_DEPTH { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; @@ -287,10 +415,28 @@ impl<'a> Mapper<'a> { } let base = *self.nodes.get(id)?; let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()); - let view = match (inst, &path) { - (Some(i), Some(p)) => View::of(base).with(i.overrides.get(p).into_iter().flatten().copied()), - _ => View::of(base), + let entries = match (inst, &path) { + (Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(), + _ => &[], }; + let mut view = View::of(base).with(entries.iter().map(|e| e.1)); + // A layer of a resized instance the file gives no derived layout for follows its + // constraints from the main's geometry, then takes the size and place overridden on it + // (auto layout places the others, as in the editor). + let derived = entries.iter().any(|(d, l)| *d == Entry::Derived && (l.get("size").is_some() || l.get("transform").is_some())); + let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID")) + && view.str("stackPositioning") != Some("ABSOLUTE"); + let main = View::of(base); + let set = |k: &str| entries.iter().rev().filter(|e| e.0 != Entry::Stale).find_map(|(_, l)| l.get(k)); + if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) { + let t = main.transform().unwrap_or_default(); + let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint")); + view.fitted = Some((set("size").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).unwrap_or(ft))); + } else if entries.iter().any(|(d, l)| *d == Entry::Stale && (l.get("size").is_some() || l.get("transform").is_some())) + && let Some(s) = set("size").and_then(size).or_else(|| main.size()) + { + view.fitted = Some((s, set("transform").and_then(matrix).or_else(|| main.transform()).unwrap_or_default())); + } let rest_id = match (inst, &path) { (Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")), _ => id.to_string(), @@ -338,21 +484,26 @@ impl<'a> Mapper<'a> { self.component(&mut o, &view, &rest_id); } self.prop_refs(&mut o, &view, inst); - let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { - space * view.get("transform").and_then(matrix).unwrap_or_default() - } else { - Affine::IDENTITY - }; + let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; let mut kids = Vec::new(); if ty == "INSTANCE" { if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) { + let main = self.nodes.get(&sym).map(|m| View::of(m)); + let fit = Fit::frame(main.and_then(|m| m.size()), view.size()); for c in self.children(&sym) { - kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), depth + 1)); + kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), fit, depth + 1)); } } } else { + // Inside an instance a layer's children are its main's: they follow it from the main's + // size to its own. + let fit = match (inst, ty) { + (None, _) => None, + (Some(_), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of(base).size(), view.size()), + (Some(_), _) => Fit::frame(View::of(base).size(), view.size()), + }; for c in self.children(id) { - kids.extend(self.layer(&c, inst, child_space, Some(&view), depth + 1)); + kids.extend(self.layer(&c, inst, child_space, Some(&view), fit, depth + 1)); } } if stand.is_some_and(|s| s.0 == "FRAME") { @@ -487,10 +638,10 @@ impl<'a> Mapper<'a> { if let Some(b) = v.str("blendMode").filter(|b| *b != "UNSET") { o.insert("blendMode".into(), json!(b)); } - if let Some(s) = v.get("size").and_then(vec2) { - o.insert("size".into(), s); + if let Some((x, y)) = v.size() { + o.insert("size".into(), json!({"x": x, "y": y})); } - let t = v.get("transform").and_then(matrix).unwrap_or_default(); + let t = v.transform().unwrap_or_default(); o.insert("relativeTransform".into(), rows(space * t)); // Paints, from the shared style when one is linked (the layer's own copy can be stale). let mut styles = Obj::new(); @@ -739,7 +890,8 @@ impl<'a> Mapper<'a> { vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0); let scale = |k: &str| { - let (size, norm) = (v.get("size").and_then(|s| num(s, k)), vd.get("normalizedSize").and_then(|s| num(s, k))); + let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 }); + let norm = vd.get("normalizedSize").and_then(|s| num(s, k)); size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n) }; let (sx, sy) = (scale("x"), scale("y")); @@ -908,7 +1060,15 @@ impl<'a> Mapper<'a> { } // Overrides by id path below this instance (the instance itself is addressed by its main's id). let own_path: Vec = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default(); - let mut overrides: BTreeMap, Vec<&'a Kv<'a>>> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default(); + let mut overrides: Overrides<'a> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default(); + // A nested instance's derived layout is for the size it has in its main: resized here, the + // sizes and places in it no longer hold and its layers follow their constraints instead. + let differs = |a: f64, b: f64| (a - b).abs() > 1e-6 * a.abs().max(b.abs()).max(1.0); + let resized = outer.is_some() + && match (self.nodes.get(id).and_then(|b| View::of(b).size()), v.size()) { + (Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1), + (a, b) => a.is_some() != b.is_some(), + }; let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")]; for (layer, list) in lists.into_iter().enumerate() { for ov in list { @@ -920,9 +1080,16 @@ impl<'a> Mapper<'a> { let e = overrides.entry([own_path.as_slice(), &rel].concat()).or_default(); // The outer instance's overrides win over this one's own; derived layout comes last. if layer == 0 && outer.is_some() { - e.insert(0, ov); + e.insert(0, (Entry::Override, ov)); } else { - e.push(ov); + e.push(( + match (layer, resized) { + (0, _) => Entry::Override, + (_, false) => Entry::Derived, + (_, true) => Entry::Stale, + }, + ov, + )); } } } @@ -1105,11 +1272,21 @@ impl<'a> Mapper<'a> { o.insert("style".into(), json!({"boundVariables": text_bound})); } let mut modes = Obj::new(); + let mut lost = false; for e in v.get("variableModeBySetMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() { - if let (Some(set), Some(mode)) = (e.get("variableSetID").and_then(ref_guid), e.get("variableModeID").and_then(guid)) { - modes.insert(set, json!(mode)); + let (Some(set), Some(mode)) = (e.get("variableSetID"), e.get("variableModeID").and_then(guid)) else { continue }; + match self.resource_id(set) { + Some(id) => { + modes.insert(id, json!(mode)); + } + // A mode of a collection neither named by guid nor resolvable in the file. + None if set.get("guid").is_some() || set.get("assetRef").is_some() => lost = true, + None => {} } } + if lost { + self.warn("variable modes of collections that aren't in the file were skipped"); + } if !modes.is_empty() { o.insert("explicitVariableModes".into(), Value::Object(modes)); } @@ -1128,7 +1305,7 @@ impl<'a> Mapper<'a> { found.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); let mut out = Vec::new(); for (_, id, n) in found { - let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, 0) else { break }; + let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, None, 0) else { break }; let name = n.get("name").and_then(Kv::str).unwrap_or("Style"); let desc = n.get("styleDescription").or_else(|| n.get("description")).and_then(Kv::str).unwrap_or(""); let ty = n.get("styleType").and_then(Kv::str).unwrap_or(""); @@ -1174,7 +1351,7 @@ impl<'a> Mapper<'a> { let mut variable_order = Vec::new(); let mut variables = Obj::new(); for (id, n) in of_type("VARIABLE", "sortPosition") { - let Some(set) = n.get("variableSetID").and_then(ref_guid).filter(|s| collections.contains_key(s)) else { continue }; + let Some(set) = n.get("variableSetID").and_then(|s| self.resource_id(s)).filter(|s| collections.contains_key(s)) else { continue }; let ty = n.get("variableResolvedType").and_then(Kv::str).unwrap_or(""); if !matches!(ty, "BOOLEAN" | "FLOAT" | "STRING" | "COLOR") { continue; diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 541739c71..19c4fc157 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -32,18 +32,20 @@ enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQU enum NavigationType NAVIGATE OVERLAY enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE enum EasingType IN_CUBIC OUT_CUBIC +enum ConstraintType MIN CENTER MAX STRETCH SCALE struct GUID uint sessionID, uint localID struct Color float r, float g, float b, float a struct Vector float x, float y struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 struct ColorStop Color color, float position message Image byte[] hash, string name -message VariableID GUID guid +message AssetRef string key, string version +message VariableID GUID guid, AssetRef assetRef message PropRefValue GUID defId message AnyValue bool boolValue, float floatValue, VariableID alias, Color colorValue, PropRefValue propRefValue, TextData textDataValue, string textValue, Expression expressionValue message Expression ExpressionFunction expressionFunction, VariableData[] expressionArguments message VariableTarget VariableID id -message VariableSetID GUID guid +message VariableSetID GUID guid, AssetRef assetRef message ConditionalActions Action[] actions, VariableData condition message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition @@ -70,7 +72,7 @@ message Event InteractionType interactionType message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions message Interaction Event event, Action[] actions message StartingPoint string name, string position -message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version message Blob byte[] bytes message Message NodeChange[] nodeChanges, Blob[] blobs "; @@ -665,6 +667,209 @@ fn instances_inside_their_own_main_stop() { assert!(doc.nodes().any(|n| n.name == "Self")); } +/// A collection from a library is named by its asset key, not a guid — also in the explicit mode +/// map of the frames, components and instances set it on, and in the variables' collection link. +#[test] +fn modes_of_collections_named_by_asset_import() { + let modes = arr(vec![ + o(vec![("id", g(90)), ("name", s("Dark")), ("sortPosition", s("!"))]), + o(vec![("id", g(91)), ("name", s("Light")), ("sortPosition", s("\""))]), + ]); + let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]); + let values = o(vec![( + "entries", + arr(vec![ + o(vec![("modeID", g(90)), ("variableData", color_value(rgb(0.0, 0.0, 0.0)))]), + o(vec![("modeID", g(91)), ("variableData", color_value(rgb(1.0, 1.0, 1.0)))]), + ]), + )]); + let by_asset = |key: &'static str, version: Option<&'static str>| { + let mut r = vec![("key", s(key))]; + r.extend(version.map(|v| ("version", s(v)))); + o(vec![("assetRef", o(r))]) + }; + let mode_map = |set: Kv<'static>| o(vec![("entries", arr(vec![o(vec![("variableSetID", set), ("variableModeID", g(91))])]))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page A", vec![]), + node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + // The collection itself says the asset key its references name. + node(4, Some((2, "!")), "VARIABLE_SET", "Schema", vec![("variableSetModes", modes), ("key", s("schema-collection")), ("version", s("7"))]), + node( + 5, + Some((2, "\"")), + "VARIABLE", + "surface", + vec![("variableSetID", by_asset("schema-collection", Some("7"))), ("variableResolvedType", e("COLOR")), ("variableDataValues", values)], + ), + node( + 10, + Some((1, "!")), + "FRAME", + "Light section", + vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7"))))], + ), + node( + 20, + Some((1, "\"")), + "SYMBOL", + "Button", + vec![("size", xy(100.0, 40.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", None)))], + ), + node( + 21, + Some((1, "#")), + "INSTANCE", + "Button copy", + vec![ + ("size", xy(100.0, 40.0)), + ("symbolData", o(vec![("symbolID", g(20))])), + ("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7")))), + ], + ), + // One names a collection that isn't in the file: its mode is skipped, with a warning. + node(11, Some((1, "$")), "FRAME", "Gone", vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("gone", None)))]), + ]; + let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert_eq!(doc.variables.collections.len(), 1); + assert_eq!(doc.variables.variables.len(), 1); + assert_eq!(doc.variables.variables[0].collection, "1:4"); + for name in ["Light section", "Button", "Button copy"] { + let n = named(&doc, name); + assert_eq!(n.var_modes.get("1:4").map(String::as_str), Some("1:91"), "{name}"); + } + assert!(named(&doc, "Gone").var_modes.is_empty()); + assert!(warnings.iter().any(|w| w.contains("variable modes of collections that aren't in the file")), "{warnings:?}"); +} + +#[test] +fn resized_instances_lay_their_layers_out_by_their_constraints() { + let pin = |h: &'static str, v: &'static str| vec![("horizontalConstraint", e(h)), ("verticalConstraint", e(v))]; + let layer = |id, pos, ty, name, w, h, x, y, c: Vec<(&'static str, Kv<'static>)>| { + node(id, Some((5, pos)), ty, name, [vec![("size", xy(w, h)), ("transform", at(x, y))], c].concat()) + }; + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(5, Some((1, "!")), "SYMBOL", "Logo", vec![("size", xy(132.0, 22.0)), ("transform", at(0.0, 0.0))]), + layer(6, "!", "ROUNDED_RECTANGLE", "Mark", 132.0, 22.0, 0.0, 0.0, pin("SCALE", "SCALE")), + layer(7, "\"", "ROUNDED_RECTANGLE", "Badge", 32.0, 10.0, 100.0, 12.0, pin("MAX", "MAX")), + layer(8, "#", "ROUNDED_RECTANGLE", "Bar", 112.0, 2.0, 10.0, 0.0, pin("STRETCH", "MIN")), + // A group scales its layers with it. + layer(11, "$", "FRAME", "Wordmark", 66.0, 22.0, 66.0, 0.0, [pin("SCALE", "SCALE"), vec![("resizeToFit", Kv::Bool(true))]].concat()), + node(12, Some((11, "!")), "ROUNDED_RECTANGLE", "Letter", vec![("size", xy(66.0, 22.0)), ("transform", at(0.0, 0.0))]), + node( + 9, + Some((1, "\"")), + "INSTANCE", + "Small logo", + vec![("size", xy(90.0, 15.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(5))]))], + ), + // Derived layout the file gives wins over the constraints. + node( + 10, + Some((1, "#")), + "INSTANCE", + "Derived logo", + vec![ + ("size", xy(90.0, 15.0)), + ("transform", at(0.0, 200.0)), + ("symbolData", o(vec![("symbolID", g(5))])), + ("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(50.0, 5.0))])])), + ], + ), + // A size the instance overrides: the place still follows the constraints. + node( + 13, + Some((1, "$")), + "INSTANCE", + "Resized badge", + vec![ + ("size", xy(90.0, 15.0)), + ("transform", at(0.0, 300.0)), + ( + "symbolData", + o(vec![ + ("symbolID", g(5)), + ("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(7)]))])), ("size", xy(20.0, 10.0))])])), + ]), + ), + ], + ), + ]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + let box_of = |inst: &str, name: &str| { + let i = named(&doc, inst); + let n = i.children.iter().map(|c| doc.node(*c).unwrap()).find(|n| n.name == name).unwrap(); + let t = n.transform.translation(); + [t.x, t.y, n.width, n.height].map(|x| (x * 1e3).round() / 1e3) + }; + assert_eq!(box_of("Small logo", "Mark"), [0.0, 0.0, 90.0, 15.0]); + assert_eq!(box_of("Small logo", "Badge"), [58.0, 5.0, 32.0, 10.0]); + assert_eq!(box_of("Small logo", "Bar"), [10.0, 0.0, 70.0, 2.0]); + // The main's own, then the instances'. + let mut letters: Vec<[f64; 2]> = + doc.nodes().filter(|n| n.name == "Letter").map(|n| [n.width, n.height].map(|x| (x * 1e3).round() / 1e3)).collect(); + letters.sort_by(|a, b| a[0].total_cmp(&b[0])); + assert_eq!(letters, [[45.0, 15.0], [45.0, 15.0], [45.0, 15.0], [66.0, 22.0]]); + assert_eq!(box_of("Derived logo", "Mark")[2..], [50.0, 5.0]); + assert_eq!(box_of("Resized badge", "Badge"), [58.0, 5.0, 20.0, 10.0]); +} + +#[test] +fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { + let stretch = || vec![("horizontalConstraint", e("STRETCH")), ("verticalConstraint", e("MIN"))]; + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(20, Some((1, "!")), "SYMBOL", "Track", vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))]), + node(21, Some((20, "!")), "ROUNDED_RECTANGLE", "Fill", [vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))], stretch()].concat()), + node(30, Some((1, "\"")), "SYMBOL", "Slider", vec![("size", xy(200.0, 50.0)), ("transform", at(0.0, 100.0))]), + // The nested instance is 150 wide in its main, with the layout derived for that. + node( + 31, + Some((30, "!")), + "INSTANCE", + "Inner", + [ + vec![ + ("size", xy(150.0, 10.0)), + ("transform", at(0.0, 0.0)), + ("symbolData", o(vec![("symbolID", g(20))])), + ( + "derivedSymbolData", + arr(vec![o(vec![ + ("guidPath", o(vec![("guids", arr(vec![g(21)]))])), + ("size", xy(150.0, 10.0)), + ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), + ])]), + ), + ], + stretch(), + ] + .concat(), + ), + node( + 40, + Some((1, "#")), + "INSTANCE", + "Wide slider", + vec![("size", xy(300.0, 50.0)), ("transform", at(0.0, 200.0)), ("symbolData", o(vec![("symbolID", g(30))]))], + ), + ]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + let width = |outer: &str| { + let inner = doc.node(named(&doc, outer).children[0]).unwrap(); + (inner.width, doc.node(inner.children[0]).unwrap().width) + }; + // In its main the derived layout holds; stretched to 250 it no longer does. + assert_eq!(width("Slider"), (150.0, 150.0)); + assert_eq!(width("Wide slider"), (250.0, 250.0)); + // What else the derived data gives (the colour a variable resolves to, say) still holds. + let fill = doc.node(doc.node(named(&doc, "Wide slider").children[0]).unwrap().children[0]).unwrap(); + assert!(matches!(fill.fills.first().map(|p| &p.kind), Some(figcraft_doc::color::PaintKind::Solid { color }) if color.r == 1.0 && color.g == 0.0)); +} + #[path = "tail_tests.rs"] mod tail; diff --git a/crates/format/src/image_budget_tests.rs b/crates/format/src/image_budget_tests.rs new file mode 100644 index 000000000..58bb4da1f --- /dev/null +++ b/crates/format/src/image_budget_tests.rs @@ -0,0 +1,93 @@ +//! The images of a native file within a budget (#189): however many members each within the +//! per-member limit a file holds, the images read from it together stay within a byte and a count +//! budget. The document's own images are read first, and the ones past the budget are reported. + +use std::io::Write; + +use figcraft_doc::Document; + +use super::*; + +fn png(k: u8) -> Vec { + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([k, 7, 0, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A saved document with `n` images, and their hashes and total size. +fn file_with_images(n: u8) -> (Vec, Vec, u64) { + let mut d = Document::new("File"); + let mut hashes = Vec::new(); + let mut total = 0; + for k in 0..n { + let bytes = png(k); + total += bytes.len() as u64; + hashes.push(add_image(&mut d, bytes).unwrap().0); + } + (save_bytes(&d, None).unwrap(), hashes, total) +} + +/// `bytes` with one more member, `name` holding `data`. +fn with_member(bytes: &[u8], name: &str, data: &[u8]) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let names: Vec = z.file_names().map(str::to_string).collect(); + let mut buf = std::io::Cursor::new(Vec::new()); + let mut w = zip::ZipWriter::new(&mut buf); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for n in names { + let member = read_member(&mut z, &n, u64::MAX).unwrap(); + w.start_file(n, stored).unwrap(); + w.write_all(&member).unwrap(); + } + w.start_file(name, stored).unwrap(); + w.write_all(data).unwrap(); + w.finish().unwrap(); + buf.into_inner() +} + +#[test] +fn images_exactly_within_the_budget_all_load() { + let (bytes, hashes, total) = file_with_images(3); + let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: total }).unwrap(); + assert!(loaded.issues.is_empty(), "{:?}", loaded.issues); + assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h))); +} + +#[test] +fn images_past_the_byte_budget_are_left_out_and_reported() { + let (bytes, hashes, total) = file_with_images(3); + let loaded = load_within(&bytes, ImageBudget { images: 100, bytes: total - 1 }).unwrap(); + let held: u64 = loaded.doc.images.values().map(|i| i.bytes.len() as u64).sum(); + assert!(held < total, "{held} bytes held"); + assert_eq!(loaded.doc.images.len(), 2); + let lost: Vec<&String> = hashes.iter().filter(|h| !loaded.doc.images.contains_key(*h)).collect(); + assert_eq!(lost.len(), 1); + assert!(loaded.issues.iter().any(|i| i.contains(lost[0].as_str()) && i.contains("together")), "{:?}", loaded.issues); +} + +#[test] +fn images_past_the_count_budget_are_left_out_and_reported() { + let (bytes, _, _) = file_with_images(4); + let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: u64::MAX }).unwrap(); + assert_eq!(loaded.doc.images.len(), 3); + assert!(loaded.issues.iter().any(|i| i.contains("more than 3 images")), "{:?}", loaded.issues); +} + +#[test] +fn the_documents_own_images_are_read_before_others() { + let (bytes, hashes, total) = file_with_images(2); + // An image the document doesn't list, named to come first in the archive's order. + let extra = with_member(&bytes, "images/0000", &png(99)); + let loaded = load_within(&extra, ImageBudget { images: 2, bytes: total }).unwrap(); + assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h)), "{:?}", loaded.issues); + assert!(!loaded.issues.iter().any(|i| hashes.iter().any(|h| i.contains(h.as_str()))), "{:?}", loaded.issues); +} + +#[test] +fn a_file_within_the_shipped_budget_loads_as_before() { + let (bytes, hashes, _) = file_with_images(3); + let loaded = load_bytes_with_versions(&bytes).unwrap(); + assert!(loaded.issues.is_empty(), "{:?}", loaded.issues); + assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h))); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index a989f3f69..f88416e27 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -47,6 +47,8 @@ pub const MAX_VERSION_DESCRIPTION: usize = 2000; /// Largest version index and total size of the version snapshots read from one file. const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024; const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024; +/// Most images, and image bytes together, read from one file (members past it are left out). +const IMAGE_BUDGET: ImageBudget = ImageBudget { images: 20_000, bytes: 4 * 1024 * 1024 * 1024 }; const BRANCH_INDEX: &str = "branches/index.json"; /// Most branches a file keeps: making more is refused, extra entries in a file are skipped. pub const MAX_BRANCHES: usize = 100; @@ -453,8 +455,20 @@ pub(crate) fn read_member(z: &mut zip::ZipArchive, name: &str Ok(data) } +/// How many images, and how many bytes of them together, one file may give. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ImageBudget { + pub images: usize, + pub bytes: u64, +} + /// Read a `.figcraft` archive (or bare JSON) with its version history. pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { + load_within(bytes, IMAGE_BUDGET) +} + +/// [`load_bytes_with_versions`] reading at most `budget` of images. +pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result { let trimmed = bytes.iter().position(|b| !b.is_ascii_whitespace()).and_then(|i| bytes.get(i)); if trimmed == Some(&b'{') { let text = std::str::from_utf8(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?; @@ -464,18 +478,7 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?; let text = String::from_utf8(read_member(&mut z, "document.json", MAX_MEMBER)?).map_err(|e| FormatError::Corrupt(e.to_string()))?; let (mut doc, mut issues, own_images) = parse_json(&text)?; - let names: Vec = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect(); - let mut images = BTreeMap::new(); - let mut unread = Vec::new(); - for name in names { - let hash = name.trim_start_matches("images/").to_string(); - match read_member(&mut z, &name, MAX_MEMBER) { - Ok(data) => { - images.insert(hash, image_data(data)); - } - Err(e) => unread.push((hash, e)), - } - } + let (images, unread) = read_images(&mut z, own_images.as_ref(), budget); report_lost_images(&mut issues, &unread, own_images.as_ref(), &images); match library::read(&mut z, &images, u64::MAX) { Ok(lib) => doc.published = lib.map(Arc::new), @@ -497,6 +500,41 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { Ok(Loaded { doc, issues, versions, branches }) } +/// The archive's `images/` members by hash, within `budget`, and the ones that couldn't be read +/// with why. The document's own images (`own`) are read first, so a file past the budget loses +/// the images only its history uses before its own. +fn read_images( + z: &mut zip::ZipArchive, + own: Option<&BTreeSet>, + budget: ImageBudget, +) -> (BTreeMap, Vec<(String, FormatError)>) { + let mut hashes: Vec = z.file_names().filter_map(|n| n.strip_prefix("images/")).map(str::to_string).collect(); + hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h))); + let mut images = BTreeMap::new(); + let mut unread = Vec::new(); + let mut left = budget.bytes; + for (i, hash) in hashes.into_iter().enumerate() { + let name = format!("images/{hash}"); + if i >= budget.images { + unread.push((hash, FormatError::Corrupt(format!("the file has more than {} images", budget.images)))); + continue; + } + if z.by_name(&name).is_ok_and(|f| f.size() > left) { + let mb = budget.bytes / (1024 * 1024); + unread.push((hash, FormatError::Corrupt(format!("the file's images together pass {mb} MB")))); + continue; + } + match read_member(z, &name, left.min(MAX_MEMBER)) { + Ok(data) => { + left = left.saturating_sub(data.len() as u64); + images.insert(hash, image_data(data)); + } + Err(e) => unread.push((hash, e)), + } + } + (images, unread) +} + /// Most lost images reported one by one (the rest as a count). const MAX_IMAGE_ISSUES: usize = 20; @@ -654,14 +692,53 @@ fn read_branches(z: &mut zip::ZipArchive, images: &BTreeMap) -> Result<(), FormatError> { let bytes = save_bytes(doc, thumbnail_png)?; - let tmp = path.with_extension("figcraft.tmp"); - std::fs::write(&tmp, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", tmp.display())))?; - std::fs::rename(&tmp, path).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))?; - Ok(()) + write_file_atomic(path, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", path.display()))) +} + +/// Write `bytes` to `path` through a staging file and one atomic rename, so a reader of `path` +/// sees either the previous file or the complete new one, never a partial write. The staging file +/// is a uniquely named sibling (`.tmp-figcraft--`) created exclusively +/// (`create_new`) and written through its own handle: a file or symlink that already exists at a +/// staging name is never truncated or followed, and separate writers (also of separate processes) +/// never share a staging file. On failure only the staging file this call created is removed, and +/// `path` is replaced only on success. The rename replaces an existing destination on every +/// platform (`std::fs::rename` uses `MoveFileEx` with `MOVEFILE_REPLACE_EXISTING` on Windows). +/// (No `sync_all` yet: nothing around here promises durability across a power cut.) +#[cfg(not(target_arch = "wasm32"))] +pub fn write_file_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; + + static STAGING: AtomicU64 = AtomicU64::new(0); + // Three tries at a fresh name: the counter is unique within this process, so only a leftover + // of a dead process that now shares our process id can collide, and the next counter won't. + for _ in 0..3 { + let mut name = path.as_os_str().to_os_string(); + name.push(format!(".tmp-figcraft-{}-{}", std::process::id(), STAGING.fetch_add(1, Relaxed))); + let tmp = std::path::PathBuf::from(name); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(mut file) => { + if let Err(e) = file.write_all(bytes) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + return match std::fs::rename(&tmp, path) { + Ok(()) => Ok(()), + // Leave nothing behind when the file can't take its place. + Err(e) => { + let _ = std::fs::remove_file(&tmp); + Err(e) + } + }; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free staging name next to the file")) } #[cfg(not(target_arch = "wasm32"))] @@ -673,10 +750,14 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec), Form #[cfg(test)] mod branch_tests; #[cfg(test)] +mod image_budget_tests; +#[cfg(test)] mod image_tests; #[cfg(test)] mod large_tests; #[cfg(test)] +mod staging_tests; +#[cfg(test)] mod thumbnail_tests; #[cfg(test)] mod version_tests; diff --git a/crates/format/src/staging_tests.rs b/crates/format/src/staging_tests.rs new file mode 100644 index 000000000..1bc199e20 --- /dev/null +++ b/crates/format/src/staging_tests.rs @@ -0,0 +1,125 @@ +//! The staging write behind `save_file`: a pre-existing file or symlink at a staging name is never +//! truncated or followed, concurrent savers of one file never corrupt it, and a failed write +//! cleans up after itself and leaves the destination alone. + +use figcraft_doc::Document; + +use crate::{save_file, write_file_atomic}; + +fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-staging-tests-{name}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). +#[cfg(unix)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::unix::fs::symlink(to, from).is_ok() +} + +#[cfg(windows)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::windows::fs::symlink_file(to, from).is_ok() +} + +#[test] +fn an_existing_staging_file_is_never_written_through() { + let d = dir("existing"); + let path = d.join("a.figcraft"); + // The old deterministic staging name, held by an unrelated file: saving must pick its own + // staging name instead of truncating this one. + let old = d.join("a.figcraft.tmp"); + std::fs::write(&old, b"not yours").unwrap(); + save_file(&Document::new("Mine"), &path, None).unwrap(); + assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); + assert_eq!(crate::load_file(&path).unwrap().0.name, "Mine"); + // Just the file and the untouched staging file: no new staging leftovers. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_staging_symlink_is_never_followed() { + let d = dir("symlink"); + let path = d.join("a.figcraft"); + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"innocent").unwrap(); + let old = d.join("a.figcraft.tmp"); + if !symlink(&old, &victim) { + return; // Making a symlink needs privileges on some Windows setups. + } + save_file(&Document::new("Mine"), &path, None).unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); + assert_eq!(crate::load_file(&path).unwrap().0.name, "Mine"); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn concurrent_savers_of_one_file_never_interleave() { + let d = dir("concurrent"); + let path = d.join("a.figcraft"); + let names = ["W0", "W1", "W2", "W3"]; + for round in 0..5 { + let mut handles = Vec::new(); + for name in names { + let (path, doc) = (path.clone(), Document::new(name)); + handles.push(std::thread::spawn(move || save_file(&doc, &path, None))); + } + for h in handles { + // On Windows a rename over a file another writer just replaced can lose the race. + if cfg!(windows) { + let _ = h.join().unwrap(); + } else { + h.join().unwrap().unwrap(); + } + } + // Whatever landed is one writer's complete file, never a mix: a torn archive wouldn't load. + let (doc, _) = crate::load_file(&path).unwrap(); + assert!(names.contains(&doc.name.as_str()), "round {round}: {:?}", doc.name); + } + // No staging file was left stuck. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { + let d = dir("failure"); + // The destination is a directory: the rename fails, our staging file is removed, the + // directory is untouched. + let taken = d.join("folder.figcraft"); + std::fs::create_dir_all(&taken).unwrap(); + assert!(save_file(&Document::new("Mine"), &taken, None).is_err()); + assert!(write_file_atomic(&taken, b"payload").is_err()); + assert!(taken.is_dir()); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); + // A missing parent directory fails without creating anything anywhere. + assert!(write_file_atomic(&d.join("no").join("such.figcraft"), b"payload").is_err()); + assert!(!d.join("no").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // A read-only directory refuses the staging file, and the old destination stays. + let ro = d.join("read-only"); + std::fs::create_dir_all(&ro).unwrap(); + let dest = ro.join("keep.figcraft"); + std::fs::write(&dest, b"keep").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&ro, perms).unwrap(); + // Running as root would defeat the read-only directory: skip instead of failing. + let probe = ro.join(".probe"); + if std::fs::write(&probe, b"").is_ok() { + let _ = std::fs::remove_file(&probe); + } else { + assert!(write_file_atomic(&dest, b"payload").is_err()); + assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); + assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); + } + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(&ro, perms).unwrap(); + } + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/layout/src/depth_tests.rs b/crates/layout/src/depth_tests.rs new file mode 100644 index 000000000..f8b597a34 --- /dev/null +++ b/crates/layout/src/depth_tests.rs @@ -0,0 +1,95 @@ +//! Deeply nested layers and a resize (#210): constraints, group scaling and auto layout fill +//! follow a resize down to [`MAX_NESTING`] levels and stop there, instead of running out of stack. +//! Each case runs on a 1 MB stack, the main thread's on Windows; thousands of levels overflowed it. + +use super::*; +use figcraft_doc::{AutoLayout, Constraints, Document, HConstraint, NodeKind, VConstraint}; + +/// Far deeper than the nesting the layout follows. +const DEEP: usize = 5000; + +fn on_small_stack(f: impl FnOnce() + Send + 'static) { + std::thread::Builder::new().stack_size(1 << 20).spawn(f).unwrap().join().unwrap(); +} + +fn width(d: &Document, id: NodeId) -> f64 { + d.node(id).unwrap().width +} + +/// A 100 × 100 frame holding `depth` frames, each inside the last and stretching with it; the +/// root and the chain from the top. +fn stretching_frames(depth: usize) -> (Document, NodeId, Vec) { + let mut d = Document::new("deep"); + let page = d.first_page().unwrap(); + let root = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let mut chain = Vec::new(); + let mut parent = root; + for _ in 0..depth { + let f = d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(f).unwrap().constraints = Constraints { horizontal: HConstraint::LeftRight, vertical: VConstraint::TopBottom }; + chain.push(f); + parent = f; + } + (d, root, chain) +} + +#[test] +fn deeply_nested_frames_follow_their_constraints_without_running_out_of_stack() { + on_small_stack(|| { + let (mut d, root, chain) = stretching_frames(DEEP); + set_size(&mut d, root, 200.0, 200.0); + apply_constraints(&mut d, root, 100.0, 100.0); + assert_eq!(width(&d, chain[0]), 200.0); + assert_eq!(width(&d, chain[MAX_NESTING - 2]), 200.0); + assert_eq!(width(&d, chain[DEEP - 1]), 100.0, "past the nesting the layout follows, layers stay"); + }); +} + +#[test] +fn a_shallow_chain_follows_all_the_way_down() { + let (mut d, root, chain) = stretching_frames(20); + set_size(&mut d, root, 200.0, 200.0); + apply_constraints(&mut d, root, 100.0, 100.0); + assert!(chain.iter().all(|f| width(&d, *f) == 200.0)); +} + +#[test] +fn deeply_nested_groups_scale_without_running_out_of_stack() { + on_small_stack(|| { + let mut d = Document::new("deep"); + let mut parent = d.first_page().unwrap(); + let root = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap(); + parent = root; + let mut chain = Vec::new(); + for _ in 0..DEEP { + parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap(); + chain.push(parent); + } + scale_group_children(&mut d, root, 2.0, 2.0); + assert_eq!(width(&d, chain[0]), 200.0); + }); +} + +#[test] +fn deeply_nested_fill_frames_lay_out_without_running_out_of_stack() { + on_small_stack(|| { + let mut d = Document::new("deep"); + let page = d.first_page().unwrap(); + let root = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(root).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], ..AutoLayout::default() }); + let mut parent = root; + let mut chain = Vec::new(); + for _ in 0..DEEP { + let f = d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 10.0, 10.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.layout = Some(AutoLayout { padding: [0.0; 4], ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Fill; + chain.push(f); + parent = f; + } + d.get_mut(root).unwrap().width = 300.0; + layout_frame(&mut d, root, &NoMeasure); + assert_eq!(width(&d, chain[0]), 300.0); + assert_eq!(width(&d, chain[DEEP - 1]), 10.0, "past the nesting the layout follows, layers stay"); + }); +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index fd7ff00b2..02e7bdaf8 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -20,6 +20,30 @@ use figcraft_doc::{ }; use figcraft_geom::{Affine, Point, Rect}; +/// Most layout calls nested in one another (about one per level of layers): deeper layers don't +/// follow a resize, so a deeply nested document can't run the stack out (#210). +pub const MAX_NESTING: usize = 256; + +thread_local! { + static NESTING: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// One level of nested layout calls, released when dropped. +struct Nested; + +impl Nested { + /// Enter one more level; `None` when [`MAX_NESTING`] are in progress. + fn enter() -> Option { + NESTING.with(|n| (n.get() < MAX_NESTING).then(|| n.set(n.get() + 1)).map(|()| Self)) + } +} + +impl Drop for Nested { + fn drop(&mut self) { + NESTING.with(|n| n.set(n.get().saturating_sub(1))); + } +} + /// Measures text for a given width (auto-height text inside a Fill container). pub trait Measure { /// The height a text node needs at `width`. @@ -311,6 +335,7 @@ struct Item { /// Lay out one auto layout frame's children and size the frame if it hugs. pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { + let Some(_nested) = Nested::enter() else { return }; let Some(f) = doc.node(frame) else { return }; let Some(al) = f.layout.clone() else { return }; let horizontal = matches!(al.mode, LayoutMode::Horizontal); @@ -893,6 +918,7 @@ fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measu /// (recursively for children frames that resize). In an auto layout frame only the children that /// ignore auto layout (absolute position) follow constraints; the layout places the others. pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) { + let Some(_nested) = Nested::enter() else { return }; let Some(f) = doc.node(id) else { return }; let absolute_only = f.layout.is_some(); let (nw, nh) = (f.width, f.height); @@ -977,6 +1003,7 @@ pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) { /// Each child keeps its rotation and flip and scales along its own axes ([`axis_factors`]), its /// centre following the group's scale. pub fn scale_group_children(doc: &mut Document, id: NodeId, kx: f64, ky: f64) { + let Some(_nested) = Nested::enter() else { return }; let Some(n) = doc.node(id) else { return }; for c in n.children.clone() { let Some(cn) = doc.node(c) else { continue }; @@ -1101,6 +1128,8 @@ mod behaviour_tests; #[cfg(test)] mod content_align_tests; #[cfg(test)] +mod depth_tests; +#[cfg(test)] mod nested_tests; #[cfg(test)] mod tests; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b8d3f3ad0..9704aab1a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -3,6 +3,7 @@ //! behave the same. use base64::Engine as _; +use figcraft_engine::params::normalize_paint; use serde_json::{Map, Value, json}; use crate::backend::{Backend, decode_data, page_children}; @@ -127,7 +128,7 @@ pub fn tool_definitions() -> Vec { tool( "batch", "Run commands as one step", - "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. Note: later commands cannot reference ids created by earlier ones in the same batch (ids are only known afterwards), so create containers first, then batch the edits.", + "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use a layer an earlier step created: anywhere in its params, {\"$step\": n} stands for the `id` in step n's result (zero-based; {\"$step\": n, \"field\": \"…\"} picks another field). Example: [{\"command\":\"node.create\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$step\":0}}}] builds a frame and a text inside it in one call.", obj( json!({ "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}}), &["command"])}, @@ -369,74 +370,17 @@ fn bounds_of(b: &mut dyn Backend, ids: &[u64]) -> Option<(f64, f64, f64, f64)> { acc } -/// Make paint/effect JSON friendlier than the engine's strict serde: any `"color"` given as a CSS -/// string ("#RRGGBB[AA]", "rgb(…)") becomes the engine's {r,g,b,a} object, gradient paints get -/// default handles (top-to-bottom) when `from`/`to`/`width` are missing, and gradient stops given -/// as plain colours are spread evenly. +/// Make paint/effect JSON friendlier than the engine's strict serde (see +/// [`figcraft_engine::params::normalize_paint`]). The engine reads paints this way itself; effects +/// still need it here. pub fn normalize_props(props: &mut Map) { for k in ["fills", "strokes", "effects"] { if let Some(v) = props.get_mut(k) { - normalize_paintish(v, 0); + normalize_paint(v); } } } -fn normalize_paintish(v: &mut Value, depth: usize) { - if depth > 16 { - return; - } - match v { - Value::Array(a) => { - for x in a { - normalize_paintish(x, depth + 1); - } - } - Value::Object(o) => { - if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(figcraft_engine::params::color_of) { - o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null)); - } - let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient")); - if is_gradient { - // Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}. - if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase) - && t != "gradient" - { - let kind = t.trim_end_matches("gradient").to_string(); - o.insert("type".into(), json!("gradient")); - o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind })); - } - o.entry("kind").or_insert(json!("linear")); - let linear = o.get("kind").and_then(Value::as_str) == Some("linear"); - let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) }; - o.entry("from").or_insert(json!(from)); - o.entry("to").or_insert(json!(to)); - // A linear gradient's width handle goes square to its axis (else its bands skew, - // or vanish when the handle lies on the axis). - let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); - if linear - && !o.contains_key("width") - && let (Some(f), Some(t)) = (pt("from"), pt("to")) - { - o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)])); - } - o.entry("width").or_insert(json!(width)); - if let Some(Value::Array(stops)) = o.get_mut("stops") { - let n = stops.len(); - for (i, s) in stops.iter_mut().enumerate() { - if let Some(c) = figcraft_engine::params::color_of(s).filter(|_| s.is_string()) { - let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 }; - *s = json!({"position": pos, "color": c}); - } else { - normalize_paintish(s, depth + 1); - } - } - } - } - } - _ => {} - } -} - /// [`normalize_props`] for command params: `props`, flat node.set keys, `paint`, `effect`. fn friendly_params(p: &mut Value) { let Some(o) = p.as_object_mut() else { return }; @@ -446,7 +390,7 @@ fn friendly_params(p: &mut Value) { normalize_props(o); for k in ["paint", "effect"] { if let Some(v) = o.get_mut(k) { - normalize_paintish(v, 0); + normalize_paint(v); } } } diff --git a/crates/mcp/tests/agent_tasks.rs b/crates/mcp/tests/agent_tasks.rs index cc7974abc..b35f3e7fd 100644 --- a/crates/mcp/tests/agent_tasks.rs +++ b/crates/mcp/tests/agent_tasks.rs @@ -307,3 +307,40 @@ fn task_vector_edit_keys() { assert_eq!(c.page_children().len(), 1); assert!(c.call("run_command", json!({"command": "vector.edit.state", "params": {}}))["isError"] == true, "Enter left edit mode"); } + +/// A whole card in one `batch` call: later steps reach the frames earlier steps created through +/// `{"$step": n}`, so the tree is built in one round-trip and one undo step. +#[test] +fn task_card_in_one_batch() { + let mut c = Client::new(); + let out = c.ok( + "batch", + json!({"label": "Card", "commands": [ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "width": 320, "height": 50, + "props": {"layout": "vertical", "padding": 24, "gap": 12, "sizingV": "hug"}}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}, "text": "Ada Lovelace"}}, + {"command": "node.create", "params": {"type": "frame", "parent": {"$step": 0}, "name": "Button", "height": 44, + "props": {"layout": "horizontal", "sizingH": "fill"}}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$step": 2}, "text": "Follow"}}, + {"command": "node.set", "params": {"ids": [{"$step": 2}], "props": {"fill": "#3B5BDB"}}}, + ]}), + ); + let results = ids(&out); + assert_eq!(results.len(), 5); + let (card, button) = (results[0]["id"].clone(), results[2]["id"].clone()); + let n = c.node(&card); + assert_eq!(ids(&n["children"]).len(), 2, "title and button are inside the card"); + let b = c.node(&button); + assert_eq!(ids(&b["children"]).len(), 1, "the label is inside the button"); + assert_eq!(b["fills"][0]["color"].as_str().unwrap().to_ascii_uppercase(), "#3B5BDB"); + assert_eq!(c.doc()["history"]["undo"][0], "Card", "one undo step"); + // A forward reference fails the whole batch and names the step. + let e = c.err( + "batch", + json!({"commands": [ + {"command": "node.create", "params": {"type": "frame", "parent": {"$step": 1}}}, + {"command": "node.create", "params": {"type": "frame"}}, + ]}), + ); + assert!(e.contains("step 0") && e.contains("earlier step"), "{e}"); +} diff --git a/crates/render/src/backdrop_shadow_tests.rs b/crates/render/src/backdrop_shadow_tests.rs new file mode 100644 index 000000000..9c5fb5627 --- /dev/null +++ b/crates/render/src/backdrop_shadow_tests.rs @@ -0,0 +1,79 @@ +//! A drop shadow under a layer with a background blur (#255): the shadow is cast by what the layer +//! paints (a translucent fill casts a faint one), not by the blurred backdrop drawn through its +//! shape, and the blur reads what lies under the layer with its own shadow there, so a shadow +//! that shows behind transparent areas is seen, blurred, through the layer. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Blur, Document, Effect, EffectKind, NodeId, NodeKind, Shadow}; + +use super::*; + +/// A white 240 × 200 frame with blue and green stripes and, on top, a pane at (60, 40) 120 × 80 +/// filled 20% black, with a hard black drop shadow 40 down (below it and under its lower half) +/// and, when `blur`, a background blur. +fn scene(show_behind: bool, blur: bool) -> (Document, NodeId) { + let mut d = Document::new("shadow behind blur"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "Frame", 0.0, 0.0, 240.0, 200.0).unwrap(); + d.get_mut(f).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())]; + for (i, x) in [20.0, 80.0, 140.0, 200.0].into_iter().enumerate() { + let s = d.create(f, NodeKind::Rectangle, "Stripe", x, 0.0, 20.0, 200.0).unwrap(); + d.get_mut(s).unwrap().fills = vec![Paint::solid(Color::from_hex(if i % 2 == 0 { "2040E0" } else { "20C060" }).unwrap())]; + } + let pane = d.create(f, NodeKind::Rectangle, "Pane", 60.0, 40.0, 120.0, 80.0).unwrap(); + let n = d.get_mut(pane).unwrap(); + n.fills = vec![Paint::solid(Color::new(0.0, 0.0, 0.0, 0.2))]; + let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 1.0), offset: (0.0, 40.0), radius: 0.0, show_behind, ..Shadow::default() }; + n.effects = vec![Effect { kind: EffectKind::DropShadow(shadow), visible: true }]; + if blur { + n.effects.push(Effect { kind: EffectKind::BackgroundBlur(Blur::uniform(8.0)), visible: true }); + } + (d, f) +} + +fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] { + let i = ((y * p.width() + x) * 4) as usize; + [p.data()[i], p.data()[i + 1], p.data()[i + 2], p.data()[i + 3]] +} + +/// The largest channel difference between `a` and `b` over the shadow below the pane +/// (60..180 × 122..160). +fn max_diff_below_pane(a: &Pixmap, b: &Pixmap) -> u8 { + let mut worst = 0; + for y in 122..160 { + for x in 60..180 { + worst = worst.max(px(a, x, y).iter().zip(px(b, x, y)).map(|(c, d)| c.abs_diff(d)).max().unwrap()); + } + } + worst +} + +#[test] +fn a_background_blur_doesnt_change_the_shadow_its_layer_casts() { + for show_behind in [false, true] { + let (with_blur, f) = scene(show_behind, true); + let (without, _) = scene(show_behind, false); + let got = render_node(&with_blur, f, 1.0).unwrap(); + let want = render_node(&without, f, 1.0).unwrap(); + let diff = max_diff_below_pane(&got, &want); + assert!(diff <= 2, "show behind {show_behind}: the shadow below the pane differs by {diff}"); + // A 20% fill casts a faint shadow: the white between the stripes stays light. + let light = px(&got, 120, 140); + assert!(light[0] > 180, "show behind {show_behind}: {light:?}"); + } +} + +#[test] +fn a_shadow_showing_behind_is_seen_through_the_background_blur() { + let (d, f) = scene(true, true); + let got = render_node(&d, f, 1.0).unwrap(); + // Between the stripes, the pane's top (no shadow under it) and its lower half (its own shadow + // under it, from y = 80). + let (top, low) = (px(&got, 120, 50), px(&got, 120, 110)); + assert!(top[0] > low[0] + 20, "the shadow under the pane doesn't show through: top {top:?}, lower half {low:?}"); + // Hidden behind the layer, it doesn't. + let (d, f) = scene(false, true); + let got = render_node(&d, f, 1.0).unwrap(); + let (top, low) = (px(&got, 120, 50), px(&got, 120, 110)); + assert!(top[0].abs_diff(low[0]) <= 2, "a hidden shadow shows: top {top:?}, lower half {low:?}"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index be839b277..877f93727 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -423,16 +423,18 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep let shape = node_outline(ctx.doc, n); // Background blur and glass: the whole backdrop under the layer (every surface below this // one, composited), through the shape, under the content. - if backdrop::has_effects(n) - && let Some(sh) = &backdrop::outline(ctx.doc, n) - { - let pad = backdrop::pad(n, ctx.scale); - let p = i32::try_from(pad).unwrap_or(0); - if let Some(back) = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2) - && let Some(b) = backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale) - { - layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None); - } + let back_shape = if backdrop::has_effects(n) { backdrop::outline(ctx.doc, n) } else { None }; + let read_backdrop = |pix: &Pixmap| back_shape.as_ref().and_then(|sh| backdrop_layer(ctx, pix, n, sh, (lx, ly), (lw, lh), lxf)); + // With drop shadows too, the layer casts them from what it paints, not from the backdrop, and + // the backdrop is read again once they lie under it (a shadow showing behind is seen through + // it). Until then, the layer's content sees the backdrop read first under it (#255). + let casts = back_shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::DropShadow(_))); + // Kept apart, the first read is only what backdrop effects inside the content see: without + // any, the backdrop (blur or glass, the costly part) is computed once, after the shadows. + let nested = || ctx.doc.subtree(id).iter().skip(1).any(|c| ctx.doc.node(*c).is_some_and(backdrop::has_effects)); + let first_back = if !casts || nested() { read_backdrop(pix) } else { None }; + if !casts && let Some(b) = &first_back { + layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None); } let op = n.opacity.clamp(0.0, 1.0); { @@ -448,9 +450,12 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep } }; let has_inner = shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::InnerShadow(_))); - // Backdrops further down see this layer composited onto `pix`. + // Backdrops further down see this layer composited onto `pix` (over its backdrop, when + // that is kept apart for now). let under = Under { opacity: op, blend: n.blend, ..Under::new(pix, (lx, ly), ctx.under) }; - draw_content(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap))); + let over_back = first_back.as_ref().filter(|_| casts).map(|b| Under::new(b, (0, 0), Some(&under))); + let content_under = over_back.as_ref().unwrap_or(&under); + draw_content(&Ctx { under: Some(content_under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap))); } // A group is no outline of its own: its inner shadows are cast from around what its layers // draw together, over them. @@ -465,24 +470,60 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep } } // Layer blur, noise and texture, in list order, over everything else. + if !casts { + layer_effects(ctx, &mut layer, n, id, lxf); + } + // Drop shadows go under the layer, in effect-list order. + { + // Kept apart, the backdrop isn't in the layer yet: the shadows are cast from the content + // with the layer effects it will get (a copy only when it gets some). + let layer_fx = + n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::LayerBlur(_) | EffectKind::Noise(_) | EffectKind::Texture(_))); + let content = (casts && layer_fx).then(|| { + let mut c = layer.clone(); + layer_effects(ctx, &mut c, n, id, lxf); + c + }); + let caster = shadow::Caster::new(ctx.doc, n, content.as_ref().unwrap_or(&layer), lxf, ctx.scale); + for e in n.effects.iter().filter(|e| e.visible) { + if let EffectKind::DropShadow(s) = &e.kind + && let Some(sh) = shadow::drop_shadow(&caster, s) + { + composite(pix, (lx, ly), &sh, op, s.blend); + } + } + } + if casts { + // The backdrop with the shadows under it, the content over it, then the layer effects. + if let Some(mut b) = read_backdrop(pix) { + b.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None); + layer = b; + } + layer_effects(ctx, &mut layer, n, id, lxf); + } + composite(pix, (lx, ly), &layer, op, n.blend); +} + +/// Node `n`'s backdrop effects through its backdrop outline `sh`, for its `size` layer at `at` on +/// `pix` (local → layer pixels through `lxf`): what `pix` holds there now, over what lies under it. +fn backdrop_layer(ctx: &Ctx, pix: &Pixmap, n: &Node, sh: &shadow::Outline, at: (i32, i32), size: (u32, u32), lxf: Affine) -> Option { + let ((lx, ly), (lw, lh)) = (at, size); + let pad = backdrop::pad(n, ctx.scale); + let p = i32::try_from(pad).unwrap_or(0); + let back = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2)?; + backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale) +} + +/// Node `n`'s layer blur, noise and texture on its `layer`, in list order. +fn layer_effects(ctx: &Ctx, layer: &mut Pixmap, n: &Node, id: NodeId, lxf: Affine) { for e in n.effects.iter().filter(|e| e.visible) { match &e.kind { - EffectKind::LayerBlur(b) => blur::apply(&mut layer, b, (n.width, n.height), lxf, ctx.scale), - EffectKind::Noise(nz) => grain::noise(&mut layer, nz, lxf, id.0 as u32), - EffectKind::Texture(t) => grain::texture(&mut layer, t, lxf, ctx.scale, id.0 as u32), + EffectKind::LayerBlur(b) => blur::apply(layer, b, (n.width, n.height), lxf, ctx.scale), + EffectKind::Noise(nz) => grain::noise(layer, nz, lxf, id.0 as u32), + EffectKind::Texture(t) => grain::texture(layer, t, lxf, ctx.scale, id.0 as u32), _ => {} } } - // Drop shadows go under the layer, in effect-list order. - let caster = shadow::Caster::new(ctx.doc, n, &layer, lxf, ctx.scale); - for e in n.effects.iter().filter(|e| e.visible) { - if let EffectKind::DropShadow(s) = &e.kind - && let Some(sh) = shadow::drop_shadow(&caster, s) - { - composite(pix, (lx, ly), &sh, op, s.blend); - } - } - composite(pix, (lx, ly), &layer, op, n.blend); } fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) { @@ -531,21 +572,50 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, NodeKind::Text(t) => { let Some((layout, dy)) = figcraft_text::layout_node(n) else { return }; let gxf = xf * Affine::translate((0.0, dy)); + // Colour glyphs (emoji) carry their own ink, which draws instead of their outline: a + // bitmap the font ships, or COLR layers with a colour each. + let gs = gxf.determinant().abs().sqrt(); + let mut ink: Vec<(usize, Ink)> = Vec::new(); // Group glyphs by style so runs with their own fills paint with them. let mut by_style: HashMap = HashMap::new(); for g in &layout.glyphs { - by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied()); + match g.color(gs) { + Some(figcraft_text::ColorInk::Bitmap(bitmap, place)) => ink.push((g.style, Ink::Bitmap(bitmap, place))), + Some(figcraft_text::ColorInk::Layers(layers)) => { + for layer in layers.iter() { + ink.push((g.style, Ink::Layer(g.layer_path(layer.gid), layer.rgba))); + } + } + None => by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied()), + } } for d in &layout.decorations { by_style.entry(d.style).or_default().extend(d.path().elements().iter().copied()); } - let mut keys: Vec = by_style.keys().copied().collect(); + // A run can have ink and no outline at all (bitmap-only fonts). + let mut keys: Vec = by_style.keys().copied().chain(ink.iter().map(|(style, _)| *style)).collect(); keys.sort_unstable(); - // Every run's fills, then what paints over the fills, then the strokes on top. + keys.dedup(); + // Every run's fills (its ink included), then what paints over the fills, then the + // strokes on top. for k in &keys { - let Some(tp) = by_style.get(k).and_then(ts_path) else { continue }; let fills = layout.styles.get(*k).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills); - fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None); + if let Some(tp) = by_style.get(k).and_then(ts_path) { + fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None); + } + for (_, item) in ink.iter().filter(|(style, _)| style == k) { + match item { + Ink::Bitmap(bitmap, place) => blit(pix, bitmap, ts_transform(gxf * *place)), + Ink::Layer(path, rgba) => { + if let Some(tp) = ts_path(path) { + let mut paint = tiny_skia::Paint::default(); + paint.set_color_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]); + paint.anti_alias = true; + pix.fill_path(&tp, &paint, FillRule::Winding, ts_transform(gxf), None); + } + } + } + } } if let Some(f) = over_fills { f(pix); @@ -687,6 +757,23 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept backdrop::replace(pix, (lx, ly), &layer, mask.as_ref().map(|m| (m, (0, 0)))); } +/// Colour ink of a text run, in the order the run paints it. Strokes follow the outlines only: a +/// bitmap-strike glyph has no outline to stroke. +enum Ink { + /// An embedded bitmap and the transform from its pixels into layout coordinates. + Bitmap(Arc, Affine), + /// One COLR layer: its outline in layout coordinates and the colour to fill it with. + Layer(BezPath, [u8; 4]), +} + +/// Draw a colour bitmap (premultiplied RGBA) under a transform, sampled smoothly when the +/// transform scales it. +fn blit(pix: &mut Pixmap, bitmap: &figcraft_text::ColorBitmap, transform: Transform) { + let Some(image) = tiny_skia::PixmapRef::from_bytes(&bitmap.rgba, bitmap.width, bitmap.height) else { return }; + let paint = PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..Default::default() }; + pix.draw_pixmap(0, 0, image, &paint, transform, None); +} + fn is_axis_aligned(xf: Affine) -> bool { let c = xf.as_coeffs(); c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && fract_ok(c[4]) && fract_ok(c[5]) diff --git a/crates/render/src/image_budget_tests.rs b/crates/render/src/image_budget_tests.rs new file mode 100644 index 000000000..6228efc56 --- /dev/null +++ b/crates/render/src/image_budget_tests.rs @@ -0,0 +1,125 @@ +//! Decoded images within a memory budget (#203): an image too large is refused from its header, +//! before its pixels are decoded, and the decoded-image caches hold a bounded number of bytes +//! however many images, filters and colour profiles a document uses. + +use std::sync::Arc; + +use figcraft_color::ColorProfile; +use tiny_skia::Pixmap; + +use super::*; + +fn png(w: u32, h: u32) -> Vec { + let img = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 40, 40, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A PNG whose header claims `w` × `h` but which holds no pixel data at all. +fn png_header_only(w: u32, h: u32) -> Vec { + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&w.to_be_bytes()); + ihdr.extend_from_slice(&h.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + out.extend_from_slice(&13u32.to_be_bytes()); + let chunk: Vec = b"IHDR".iter().copied().chain(ihdr).collect(); + out.extend_from_slice(&chunk); + out.extend_from_slice(&crc32(&chunk).to_be_bytes()); + out +} + +fn crc32(data: &[u8]) -> u32 { + let mut crc = !0u32; + for b in data { + crc ^= u32::from(*b); + for _ in 0..8 { + crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + } + } + !crc +} + +#[test] +fn an_image_within_the_limits_decodes() { + let (p, _) = images::decode_within(&png(30, 20), ColorProfile::Srgb, 30, 30 * 20 * 4).unwrap(); + assert_eq!((p.width(), p.height()), (30, 20)); +} + +#[test] +fn an_image_wider_or_taller_than_the_limit_is_refused() { + assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 29, u64::MAX).is_none()); + assert!(images::decode_within(&png(20, 30), ColorProfile::Srgb, 29, u64::MAX).is_none()); +} + +#[test] +fn an_image_whose_pixels_pass_the_byte_budget_is_refused() { + assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 1000, 30 * 20 * 4 - 1).is_none()); +} + +#[test] +fn a_huge_image_is_refused_from_its_header_alone() { + // 16 000 × 16 000 is within the side limit; its pixels (1 GB) aren't within a 1 MB budget, and + // the file has none to decode: only the header is read. + assert!(images::decode_within(&png_header_only(16_000, 16_000), ColorProfile::Srgb, 16_384, 1 << 20).is_none()); + // Past the side limit, the shipped decode refuses it the same way. + assert!(images::decode(&png_header_only(20_000, 10)).is_none()); +} + +fn key(name: &str) -> images::Key { + (name.to_string(), [0; 7], ColorProfile::Srgb) +} + +fn pixmap(side: u32) -> Arc { + Arc::new(Pixmap::new(side, side).unwrap()) +} + +#[test] +fn the_image_cache_keeps_within_its_byte_budget() { + // 10 × 10 pixmaps are 400 bytes; 1000 bytes hold two. + let mut c = images::PixmapCache::default(); + for name in ["a", "b", "c", "d", "e"] { + c.insert(key(name), pixmap(10), 1000); + assert!(c.bytes() <= 1000, "{} bytes held", c.bytes()); + assert!(c.get(&key(name)).is_some(), "{name} was just added"); + } +} + +#[test] +fn a_full_image_cache_drops_the_least_recently_used_image_only() { + // 1000 bytes hold two 400-byte pixmaps: the third drops the one used longest ago. + let mut c = images::PixmapCache::default(); + c.insert(key("a"), pixmap(10), 1000); + c.insert(key("b"), pixmap(10), 1000); + assert!(c.get(&key("a")).is_some()); + c.insert(key("c"), pixmap(10), 1000); + assert!(c.get(&key("a")).is_some(), "used since b: kept"); + assert!(c.get(&key("b")).is_none(), "used longest ago: dropped"); + assert!(c.get(&key("c")).is_some()); + assert_eq!(c.bytes(), 800); +} + +#[test] +fn an_image_larger_than_the_budget_is_held_alone() { + let mut c = images::PixmapCache::default(); + c.insert(key("small"), pixmap(10), 1000); + c.insert(key("big"), pixmap(20), 1000); + assert!(c.get(&key("big")).is_some()); + assert!(c.get(&key("small")).is_none()); + assert_eq!(c.bytes(), 20 * 20 * 4); + // The next image takes its place. + c.insert(key("next"), pixmap(10), 1000); + assert_eq!(c.bytes(), 400); +} + +#[test] +fn filter_and_profile_variants_count_against_one_budget() { + let mut c = images::PixmapCache::default(); + for (i, profile) in [ColorProfile::Srgb, ColorProfile::DisplayP3].into_iter().enumerate() { + for f in 0..4 { + c.insert(("img".to_string(), [f, 0, 0, 0, 0, 0, i as i32], profile), pixmap(10), 1000); + assert!(c.bytes() <= 1000, "{} bytes held", c.bytes()); + } + } +} diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index abdb9e1f4..f4f9c5d94 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -13,11 +13,69 @@ use figcraft_color::{ColorProfile, ImageFilters, PaintKind}; use figcraft_doc::{Document, ImageData, NodeId}; use tiny_skia::Pixmap; -type Key = (String, [i32; 7], ColorProfile); +pub(crate) type Key = (String, [i32; 7], ColorProfile); -fn cache() -> &'static Mutex>> { - static C: OnceLock>>> = OnceLock::new(); - C.get_or_init(|| Mutex::new(HashMap::new())) +/// The largest image side decoded. +const MAX_SIDE: u32 = 16_384; +/// The most bytes one image's decoding may allocate: the image crate's own default limit (a +/// larger image is refused from its header). +const MAX_DECODED_BYTES: u64 = 512 << 20; +/// The most bytes of pixels each decoded-image cache holds (one image larger than this is held +/// alone). +const MAX_CACHED_BYTES: u64 = 512 << 20; +/// The most entries each decoded-image cache holds. +const MAX_CACHED_IMAGES: usize = 256; + +/// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding +/// one past the budget (or past [`MAX_CACHED_IMAGES`]) drops the least recently used first, so +/// the images on screen stay while they fit. +#[derive(Default)] +pub(crate) struct PixmapCache { + /// Each pixmap and when it was last used. + map: HashMap, u64)>, + bytes: u64, + tick: u64, +} + +impl PixmapCache { + pub(crate) fn get(&mut self, key: &Key) -> Option> { + self.tick += 1; + let tick = self.tick; + self.map.get_mut(key).map(|(p, used)| { + *used = tick; + p.clone() + }) + } + + /// Add `p` under `key`, keeping the cache within `max_bytes` (or holding `p` alone). + pub(crate) fn insert(&mut self, key: Key, p: Arc, max_bytes: u64) { + let size = p.data().len() as u64; + self.remove(&key); + while self.bytes.saturating_add(size) > max_bytes || self.map.len() >= MAX_CACHED_IMAGES { + let Some(oldest) = self.map.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone()) else { break }; + self.remove(&oldest); + } + self.tick += 1; + self.map.insert(key, (p, self.tick)); + self.bytes = self.bytes.saturating_add(size); + } + + fn remove(&mut self, key: &Key) { + if let Some((p, _)) = self.map.remove(key) { + self.bytes = self.bytes.saturating_sub(p.data().len() as u64); + } + } + + /// The bytes of pixels held. + #[cfg(test)] + pub(crate) fn bytes(&self) -> u64 { + self.bytes + } +} + +fn cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(PixmapCache::default())) } fn fkey(f: &ImageFilters) -> [i32; 7] { @@ -34,7 +92,23 @@ pub fn decode(bytes: &[u8]) -> Option { /// image's own profile into `target` (`false`), or as sRGB when it has none (`true`: the sRGB → /// `target` conversion is still to do). fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> { - let mut dec = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; + decode_within(bytes, target, MAX_SIDE, MAX_DECODED_BYTES) +} + +/// [`decode_in`] for images at most `max_side` wide and tall whose decoded pixels take at most +/// `max_bytes`; larger ones are refused from their header, before any pixels are decoded. +pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> { + use image::ImageDecoder; + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(max_side); + limits.max_image_height = Some(max_side); + limits.max_alloc = Some(max_bytes); + reader.limits(limits); + let mut dec = reader.into_decoder().ok()?; + if dec.total_bytes() > max_bytes { + return None; + } let source = crate::image_profile::Source::read(bytes, &mut dec); let img = image::DynamicImage::from_decoder(dec).ok()?; match source.convert(&img, target) { @@ -66,7 +140,7 @@ pub fn profile_name(hash: &str, data: Option<&ImageData>) -> Option { /// Straight RGBA → a premultiplied pixmap (max 16k per side). fn premultiplied(rgba: &image::RgbaImage) -> Option { let (w, h) = rgba.dimensions(); - if w == 0 || h == 0 || w > 16_384 || h > 16_384 { + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { return None; } let mut p = Pixmap::new(w, h)?; @@ -87,17 +161,13 @@ fn premultiplied(rgba: &image::RgbaImage) -> Option { pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile) -> Option> { let key = (hash.to_string(), fkey(filters), profile); if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return Some(p.clone()); + return Some(p); } let (mut p, srgb) = decode_in(&data?.bytes, profile)?; // Images with their own profile are in `profile` already (sRGB → sRGB is the identity). finish(&mut p, filters, if srgb { profile } else { ColorProfile::Srgb }); let p = Arc::new(p); - let mut c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if c.len() > 256 { - c.clear(); - } - c.insert(key, p.clone()); + cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone(), MAX_CACHED_BYTES); Some(p) } @@ -166,7 +236,7 @@ pub fn decode_frames(bytes: &[u8]) -> Option> { let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?; let (w, h) = dec.dimensions(); let frame_bytes = u64::from(w) * u64::from(h) * 4; - if frame_bytes == 0 || w > 16_384 || h > 16_384 { + if frame_bytes == 0 || w > MAX_SIDE || h > MAX_SIDE { return None; } let most = (MAX_ANIMATION_BYTES / frame_bytes).min(MAX_FRAMES as u64) as usize; @@ -304,22 +374,18 @@ pub fn pad(pm: &Pixmap) -> Option { Some(out) } -fn padded_cache() -> &'static Mutex>> { - static C: OnceLock>>> = OnceLock::new(); - C.get_or_init(|| Mutex::new(HashMap::new())) +fn padded_cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(PixmapCache::default())) } /// `pm` with a transparent 1-pixel border (cached). pub fn padded(hash: &str, filters: &ImageFilters, profile: ColorProfile, pm: &Pixmap) -> Option> { let key = (hash.to_string(), fkey(filters), profile); if let Some(p) = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return Some(p.clone()); + return Some(p); } let out = Arc::new(pad(pm)?); - let mut c = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if c.len() > 256 { - c.clear(); - } - c.insert(key, out.clone()); + padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, out.clone(), MAX_CACHED_BYTES); Some(out) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c2bf9dda0..7eae5a861 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -425,6 +425,8 @@ pub fn png_insert_chunk(png: Vec, kind: &[u8; 4], data: &[u8]) -> Vec { out } +#[cfg(test)] +mod backdrop_shadow_tests; #[cfg(test)] mod backdrop_tests; #[cfg(test)] @@ -442,6 +444,8 @@ mod grain_tests; #[cfg(test)] mod group_effects_tests; #[cfg(test)] +mod image_budget_tests; +#[cfg(test)] mod image_profile_tests; #[cfg(test)] mod lod_tests; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 167cc7425..b851e9cd8 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -217,6 +217,37 @@ fn text_renders_something() { } } +/// The machine's colour emoji face, if it has one (this test skips without: no font ships here). +fn emoji_face() -> Option<(String, String)> { + figcraft_text::scan_system_fonts(); + let mut db = figcraft_text::db().write().unwrap(); + db.emoji_fallbacks().into_iter().find(|f| f.has_color()).map(|f| (f.family.clone(), f.style.clone())) +} + +#[test] +fn colour_emoji_draw_their_pixels() { + let Some((family, style)) = emoji_face() else { return }; + let mut d = figcraft_doc::Document::new("t"); + let p = d.first_page().unwrap(); + let mut td = TextData::new("\u{1F600}"); + (td.style.font_family, td.style.font_style) = (family.clone(), style.clone()); + td.style.font_size = 48.0; + d.create(p, NodeKind::Text(td), "T", 0.0, 0.0, 200.0, 64.0).unwrap(); + let pix = render_view(&d, p, &view(100, 80), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap(); + // The emoji is the only thing in this document that paints saturated colour. + let coloured = pix + .data() + .as_chunks::<4>() + .0 + .iter() + .filter(|c| { + let (r, g, b) = (i16::from(c[0]), i16::from(c[1]), i16::from(c[2])); + c[3] == 255 && (r.max(g).max(b) - r.min(g).min(b)) > 60 + }) + .count(); + assert!(coloured > 200, "{family} drew {coloured} coloured pixels"); +} + #[test] fn export_png_roundtrip() { let (d, _, r) = doc_with_rect(Color::from_hex("00FF00").unwrap()); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a5ae82adc..9f0b9f83d 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 6c5bff973..360b95ca9 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(", +} + +/// One layer of a COLR v0 glyph: the glyph that draws the layer and the palette colour it fills +/// with (straight RGBA: the renderer paints it). +#[derive(Debug)] +pub struct ColorLayer { + pub gid: u32, + pub rgba: [u8; 4], +} + +/// A glyph's colour ink: what draws instead of its outline, placed in coordinates the caller +/// supplies (a bitmap pixel maps through `place`; COLR layers are drawn like the glyph's outline). +pub enum ColorInk { + Bitmap(Arc, Affine), + Layers(Arc>), +} + +/// Colour rasters cached per face, keyed by glyph, variations and size. +#[derive(Default)] +pub(crate) struct Rasters { + entries: HashMap<(u32, Coords, u64), Option>>, + bytes: usize, +} + +impl Rasters { + fn get(&self, key: &(u32, Coords, u64)) -> Option> { + self.entries.get(key).and_then(Option::clone) + } + + fn known(&self, key: &(u32, Coords, u64)) -> bool { + self.entries.contains_key(key) + } + + fn insert(&mut self, key: (u32, Coords, u64), bitmap: Option>) { + if let Some(b) = &bitmap { + self.bytes += b.rgba.len(); + } + if self.bytes > MAX_RASTER_BYTES || self.entries.len() > MAX_RASTER_ENTRIES { + self.entries.clear(); + self.bytes = bitmap.as_ref().map(|b| b.rgba.len()).unwrap_or(0); + } + self.entries.insert(key, bitmap); + } +} + +impl Face { + /// Whether the face has colour glyphs at all: bitmaps (sbix, CBDT/EBDT) or COLR layers. + pub fn has_color(&self) -> bool { + self.has_bitmaps() || self.has_colr() + } + + /// The face has bitmap strikes (`sbix`/`CBDT`/`EBDT`) + pub fn has_bitmaps(&self) -> bool { + self.bitmap_strikes + } + + /// The face has a `COLR` table. + pub fn has_colr(&self) -> bool { + self.colr + } + + /// The glyph's embedded bitmap, scaled to `size` px per em (premultiplied RGBA), with the offset + /// of its top-left corner from the pen in px at `size` (y down). `None` when the face has no + /// bitmap for the glyph. + pub fn color_bitmap(&self, gid: u32, coords: &Coords, size: f64) -> Option> { + if !self.bitmap_strikes || !size.is_finite() || size <= 0.0 { + return None; + } + let size = size.clamp(1.0, MAX_PX_PER_EM); + let key = (gid, coords.clone(), size.to_bits()); + let mut cache = self.rasters.lock().unwrap_or_else(PoisonError::into_inner); + if cache.known(&key) { + return cache.get(&key); + } + let made = self.read_bitmap(gid, size).map(Arc::new); + cache.insert(key, made.clone()); + made + } + + /// The glyph's COLR v0 layers (empty when the glyph has none: COLR v1 glyphs, or no COLR data). + pub fn color_layers(&self, gid: u32) -> Option>> { + if !self.colr { + return None; + } + let mut cache = self.layers.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(hit) = cache.get(&gid) { + return hit.clone(); + } + let made = self.read_layers(gid).map(Arc::new); + if cache.len() >= MAX_LAYERS { + cache.clear(); + } + cache.insert(gid, made.clone()); + made + } + + /// Read a strike bitmap and place it the way Skia's fontations port does + /// (`src/ports/fontations/src/bitmap.rs`): the outer bearings are in font units and scale with + /// the em, the inner ones are pixels at the strike's ppem, and an sbix image is measured from + /// its lower-left corner (the spec places the graphic's origin at the glyph box's xMin, yMin). + fn read_bitmap(&self, gid: u32, size: f64) -> Option { + let gid = GlyphId::new(gid); + let font = FontRef::from_index(self.bytes.data(), self.index).ok()?; + let strike = BitmapStrikes::new(&font).glyph_for_size(Size::new(size as f32), gid)?; + let (rgba, width, height) = decode(&strike)?; + if width == 0 || height == 0 || rgba.len() != width as usize * height as usize * 4 { + return None; + } + let (left, top) = placement(&strike, size, self.upem); + // Scale the strike's own pixels to the requested size (the caller blits them 1:1). + let scale = size / f64::from(strike.ppem_y.max(1.0)); + let w = ((f64::from(width) * scale).round() as i64).clamp(1, MAX_PX_PER_EM as i64) as u32; + let h = ((f64::from(height) * scale).round() as i64).clamp(1, MAX_PX_PER_EM as i64) as u32; + let rgba = if (w, h) == (width, height) { rgba } else { scaled(&rgba, width, height, w, h)? }; + Some(ColorBitmap { width: w, height: h, left, top, rgba: rgba.into() }) + } + + /// Read the COLR v0 layers of a glyph (`None` for a glyph the table doesn't cover, which is + /// every glyph of a COLR v1-only font). + fn read_layers(&self, gid: u32) -> Option> { + let font = FontRef::from_index(self.bytes.data(), self.index).ok()?; + let colr = font.colr().ok()?; + let range = colr.v0_base_glyph(GlyphId::new(gid))?; + let palettes = ColorPalettes::new(&font); + let palette = palettes.get(0); + let colors = palette.as_ref().map(|p| p.colors()).unwrap_or(&[]); + let mut out = Vec::new(); + for index in range { + let (layer, color_index) = colr.v0_layer(index)?; + let rgba = colors.get(usize::from(color_index)).map(|c| [c.red(), c.green(), c.blue(), c.alpha()]).unwrap_or([0, 0, 0, 255]); + out.push(ColorLayer { gid: layer.to_u32(), rgba }); + } + (!out.is_empty()).then_some(out) + } +} + +/// Where a strike's image sits from the pen: the image's top-left corner in px at `size` (y down). +fn placement(glyph: &BitmapGlyph, size: f64, upem: f64) -> (f64, f64) { + let units = size / upem.max(1.0); + let pixels = size / f64::from(glyph.ppem_y.max(1.0)); + let left = f64::from(glyph.inner_bearing_x) * pixels + f64::from(glyph.bearing_x) * units; + let above = match glyph.placement_origin { + // sbix: the origin is the image's lower-left corner, so the whole image hangs above it. + Origin::BottomLeft => f64::from(glyph.inner_bearing_y) + f64::from(glyph.height), + Origin::TopLeft => f64::from(glyph.inner_bearing_y), + }; + (left, -above * pixels - f64::from(glyph.bearing_y) * units) +} + +/// A strike's pixels as premultiplied RGBA (what the renderer's pixmaps hold), with their size. +fn decode(glyph: &BitmapGlyph<'_>) -> Option<(Vec, u32, u32)> { + match &glyph.data { + // The OpenType spec's PNG strikes are plain PNGs (straight alpha), so they are + // premultiplied here for the renderer. + BitmapData::Png(png) => { + let (rgba, width, height) = decode_png(png)?; + Some((premultiplied(rgba), width, height)) + } + // Bit depth 32 without PNG is BGRA with premultiplied channels (the spec says so). + BitmapData::Bgra(bgra) => { + let (width, height) = (glyph.width, glyph.height); + let mut rgba = bgra.get(..width as usize * height as usize * 4)?.to_vec(); + for px in rgba.chunks_exact_mut(4) { + px.swap(0, 2); + } + Some((rgba, width, height)) + } + // Alpha-only strikes would be masks, not colour: the outline pipeline draws those glyphs. + BitmapData::Mask(_) => None, + } +} + +#[cfg(test)] +#[path = "color_tests.rs"] +mod tests; + +/// A PNG strike as straight 8-bit RGBA. +fn decode_png(png: &[u8]) -> Option<(Vec, u32, u32)> { + let mut decoder = png::Decoder::new(std::io::Cursor::new(png)); + // Expand palettes, `tRNS` and sub-byte samples to 8-bit RGBA. + decoder.set_transformations(png::Transformations::normalize_to_color8() | png::Transformations::ALPHA); + let mut reader = decoder.read_info().ok()?; + // The header's size is checked before anything is allocated for it: a font can claim any size. + let (w, h) = reader.info().size(); + if w == 0 || h == 0 || w > MAX_PNG_SIDE || h > MAX_PNG_SIDE { + return None; + } + let mut buf = vec![0u8; reader.output_buffer_size()?]; + let info = reader.next_frame(&mut buf).ok()?; + let (width, height) = (info.width, info.height); + let data = buf.get(..info.buffer_size())?; + let rgba = match info.color_type { + png::ColorType::Rgba => data.to_vec(), + png::ColorType::Rgb => data.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(), + png::ColorType::Grayscale => data.iter().flat_map(|g| [*g, *g, *g, 255]).collect(), + png::ColorType::GrayscaleAlpha => data.as_chunks::<2>().0.iter().flat_map(|p| [p[0], p[0], p[0], p[1]]).collect(), + png::ColorType::Indexed => return None, + }; + Some((rgba, width, height)) +} + +/// Straight RGBA → premultiplied RGBA (in place). +fn premultiplied(mut rgba: Vec) -> Vec { + for px in rgba.chunks_exact_mut(4) { + let a = u32::from(px[3]); + for c in &mut px[..3] { + *c = ((u32::from(*c) * a + 127) / 255) as u8; + } + } + rgba +} + +/// Premultiplied RGBA scaled to `dw`×`dh`: a box average when shrinking, bilinear when growing. +fn scaled(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Option> { + if sw == 0 || sh == 0 || dw == 0 || dh == 0 { + return None; + } + let sample = |x: u32, y: u32| -> [u8; 4] { + let i = ((y.min(sh - 1) * sw + x.min(sw - 1)) * 4) as usize; + match src.get(i..i + 4) { + Some(px) => [px[0], px[1], px[2], px[3]], + None => [0, 0, 0, 0], + } + }; + let mut out = vec![0u8; dw as usize * dh as usize * 4]; + if dw >= sw && dh >= sh { + let at = |v: f64, n: u32| ((v + 0.5) * f64::from(n) - 0.5).clamp(0.0, f64::from(n - 1)); + for y in 0..dh { + let sy = at(f64::from(y) / f64::from(dh), sh); + let (y0, fy) = (sy.floor() as u32, sy.fract()); + for x in 0..dw { + let sx = at(f64::from(x) / f64::from(dw), sw); + let (x0, fx) = (sx.floor() as u32, sx.fract()); + let (p00, p10, p01, p11) = (sample(x0, y0), sample(x0 + 1, y0), sample(x0, y0 + 1), sample(x0 + 1, y0 + 1)); + let o = ((y * dw + x) * 4) as usize; + for c in 0..4 { + let top = f64::from(p00[c]) * (1.0 - fx) + f64::from(p10[c]) * fx; + let bottom = f64::from(p01[c]) * (1.0 - fx) + f64::from(p11[c]) * fx; + out[o + c] = (top * (1.0 - fy) + bottom * fy).round().clamp(0.0, 255.0) as u8; + } + } + } + return Some(out); + } + let (fx, fy) = (f64::from(sw) / f64::from(dw), f64::from(sh) / f64::from(dh)); + for y in 0..dh { + let (y0, y1) = ((f64::from(y) * fy).floor() as u32, ((f64::from(y) + 1.0) * fy).ceil() as u32); + for x in 0..dw { + let (x0, x1) = ((f64::from(x) * fx).floor() as u32, ((f64::from(x) + 1.0) * fx).ceil() as u32); + let mut sum = [0.0f64; 4]; + let mut n = 0.0f64; + for sy in y0..y1.clamp(y0 + 1, sh) { + for sx in x0..x1.clamp(x0 + 1, sw) { + let px = sample(sx, sy); + for c in 0..4 { + sum[c] += f64::from(px[c]); + } + n += 1.0; + } + } + let o = ((y * dw + x) * 4) as usize; + for c in 0..4 { + out[o + c] = (sum[c] / n.max(1.0)).round().clamp(0.0, 255.0) as u8; + } + } + } + Some(out) +} diff --git a/crates/text/src/color_tests.rs b/crates/text/src/color_tests.rs new file mode 100644 index 000000000..9ccf0b9c7 --- /dev/null +++ b/crates/text/src/color_tests.rs @@ -0,0 +1,231 @@ +//! Colour glyph behaviour (colour emoji): bitmap strikes, COLR layers, and where they sit. +//! +//! The placement cases are pure numbers, so they run everywhere. The rest reads the machine's +//! colour emoji face and skips (like the other suites) on a machine without one; which format it +//! uses depends on the platform (Apple Color Emoji is sbix, Noto Color Emoji is CBDT, Segoe UI +//! Emoji is COLR), so a machine exercises the paths its own font has. + +use super::*; +use crate::fontdb::{db, no_coords, scan_system_fonts}; +use crate::layout::layout; +use figcraft_doc::TextData; +use skrifa::bitmap::{BitmapData, BitmapGlyph, MaskData, Origin}; + +/// A strike glyph described by hand, for the placement cases. +fn glyph(width: u32, height: u32, origin: Origin, bearing: (f32, f32), inner: (f32, f32), ppem: f32) -> BitmapGlyph<'static> { + BitmapGlyph { + data: BitmapData::Png(&[]), + bearing_x: bearing.0, + bearing_y: bearing.1, + inner_bearing_x: inner.0, + inner_bearing_y: inner.1, + ppem_x: ppem, + ppem_y: ppem, + advance: None, + width, + height, + placement_origin: origin, + } +} + +/// A strike glyph with data, for the decoding cases (the fields they don't read stay neutral). +fn strike(data: BitmapData<'_>, width: u32, height: u32) -> BitmapGlyph<'_> { + BitmapGlyph { + data, + bearing_x: 0.0, + bearing_y: 0.0, + inner_bearing_x: 0.0, + inner_bearing_y: 0.0, + ppem_x: 10.0, + ppem_y: 10.0, + advance: None, + width, + height, + placement_origin: Origin::TopLeft, + } +} + +#[test] +fn bitmap_placement_follows_the_strike() { + // CBDT: bearings are pixels from the origin to the bitmap's left and top edges, read at the + // strike's ppem and scaled to the size asked for: 2 px at ppem 10 in a 20 px run → 4 px. + let cbdt = glyph(100, 50, Origin::TopLeft, (0.0, 0.0), (2.0, 8.0), 10.0); + let (left, top) = placement(&cbdt, 20.0, 1000.0); + assert_eq!((left, top), (4.0, -16.0), "the left edge sits right of the pen, the top above it"); + + // sbix: bearings and origin offsets are font units, and the image is placed by its lower-left + // corner, so its top is the whole image above the origin. + let sbix = glyph(10, 10, Origin::BottomLeft, (-1.0, -3.0), (1.0, 2.0), 10.0); + let (left, top) = placement(&sbix, 20.0, 1000.0); + assert!((left - (2.0 - 0.02)).abs() < 1e-9, "{left}"); + assert!((top - (-24.0 + 0.06)).abs() < 1e-9, "{top}"); + + // Doubling the size doubles the offset with it. + let scaled = glyph(10, 10, Origin::TopLeft, (0.0, 0.0), (1.0, 1.0), 10.0); + assert_eq!(placement(&scaled, 40.0, 1000.0).1, -4.0); + assert_eq!(placement(&scaled, 20.0, 1000.0).1, -2.0); +} + +/// A 2×2 PNG with straight alpha, the format strikes carry: row 0 is (255,0,0,128) then +/// (0,255,0,255), row 1 is (0,0,255,0) then (10,20,30,51). +const PNG_STRAIGHT: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x72, 0xb6, 0x0d, 0x24, 0x00, 0x00, 0x00, 0x17, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0xd0, + 0xc0, 0xf0, 0x1f, 0x08, 0x19, 0x80, 0x98, 0x4b, 0x44, 0xce, 0x18, 0x00, 0x34, 0xb8, 0x04, 0xec, 0xa7, 0x3b, 0x38, 0x04, 0x00, 0x00, 0x00, 0x00, + 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +#[test] +fn png_strikes_decode_to_premultiplied_rgba() { + let (rgba, width, height) = decode(&strike(BitmapData::Png(PNG_STRAIGHT), 2, 2)).unwrap(); + assert_eq!((width, height), (2, 2)); + assert_eq!( + rgba, + vec![128, 0, 0, 128, 0, 255, 0, 255, 0, 0, 0, 0, 2, 4, 6, 51], + "straight alpha times its own alpha, rounded: the renderer's surfaces are premultiplied" + ); +} + +#[test] +fn a_png_strike_claiming_a_huge_size_is_refused_before_anything_is_allocated() { + // PNG_STRAIGHT with its header saying 1 048 576 × 1 048 576 pixels (4 TiB as RGBA). + let mut png = PNG_STRAIGHT.to_vec(); + assert_eq!(crc32(&png[12..29]).to_be_bytes(), png[29..33], "the patched header passes its checksum"); + png[16..24].copy_from_slice(&[0, 0x10, 0, 0, 0, 0x10, 0, 0]); + let crc = crc32(&png[12..29]); + png[29..33].copy_from_slice(&crc.to_be_bytes()); + assert!(decode_png(&png).is_none()); +} + +/// The CRC-32 a PNG chunk ends with. +fn crc32(bytes: &[u8]) -> u32 { + !bytes.iter().fold(!0u32, |c, b| (0..8).fold(c ^ u32::from(*b), |c, _| if c & 1 == 1 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 })) +} + +#[test] +fn raw_bgra_strikes_are_already_premultiplied() { + // BitmapData::Bgra is BGRA with premultiplied channels (the spec says so), so decoding only + // moves the red and blue channels. + let (rgba, width, height) = decode(&strike(BitmapData::Bgra(&[17, 34, 51, 255, 4, 8, 12, 16]), 2, 1)).unwrap(); + assert_eq!((width, height), (2, 1)); + assert_eq!(rgba, vec![51, 34, 17, 255, 12, 8, 4, 16]); +} + +#[test] +fn strikes_that_are_not_colour_decode_to_nothing() { + let mask = MaskData { bpp: 8, is_packed: false, data: &[0; 16] }; + assert!(decode(&strike(BitmapData::Mask(mask), 2, 2)).is_none(), "alpha-only strikes are masks, not colour"); + assert!(decode(&strike(BitmapData::Png(b"not a png"), 2, 2)).is_none(), "a broken PNG is skipped, not drawn"); + assert!(decode(&strike(BitmapData::Bgra(&[1, 2, 3]), 2, 2)).is_none(), "a buffer shorter than the glyph is skipped"); +} + +#[test] +fn scaling_keeps_the_pixels_premultiplied() { + // A 4×4 face: one straight colour over a falling alpha, premultiplied by hand. + let straight = [220u32, 180, 60]; + let ramp = [255u32, 190, 128, 64, 0]; + let mut src = Vec::new(); + for y in 0..4 { + for x in 0..4 { + let a = ramp[(x + y) % ramp.len()]; + src.extend(straight.iter().map(|c| ((c * a + 127) / 255) as u8)); + src.push(a as u8); + } + } + for (dw, dh) in [(2, 2), (3, 4), (9, 7), (16, 16)] { + let out = scaled(&src, 4, 4, dw, dh).unwrap(); + assert_eq!(out.len(), (dw * dh * 4) as usize); + for px in out.chunks_exact(4) { + assert!(px[0] <= px[3] && px[1] <= px[3] && px[2] <= px[3], "{dw}×{dh}: {px:?}"); + } + } + assert!(scaled(&src, 4, 4, 0, 4).is_none() && scaled(&src, 0, 0, 4, 4).is_none(), "an empty side scales to nothing"); +} + +/// The machine's colour emoji face, if it has one (tests skip without: no font ships in the repo). +fn emoji_face() -> Option> { + scan_system_fonts(); + let mut db = db().write().unwrap(); + db.emoji_fallbacks().into_iter().find(|f| f.has_color()) +} + +#[test] +fn colour_emoji_carry_their_own_pixels() { + let Some(face) = emoji_face() else { return }; + // A grinning face: a bitmap strike (Apple, Noto) or COLR layers (Segoe) must cover it. + let gid = face.glyph_for('\u{1F600}'); + assert_ne!(gid, 0, "U+1F600 is not in {}", face.family); + if let Some(layers) = face.color_layers(gid) { + assert!(!layers.is_empty()); + assert!(layers.len() < 64, "{} layers", layers.len()); + assert!(layers.iter().all(|l| l.gid != 0), "every layer names a glyph: {layers:?}"); + return; + } + let Some(bitmap) = face.color_bitmap(gid, &no_coords(), 32.0) else { + panic!("{} has colour tables but draws nothing for U+1F600", face.family); + }; + // The image is a sane size for 32 px of text and its pixels are premultiplied (what the + // renderer's surfaces hold), so no channel is brighter than its alpha. + assert!(bitmap.width >= 8 && bitmap.width <= 128, "{} px wide", bitmap.width); + assert!(bitmap.height >= 8 && bitmap.height <= 128, "{} px tall", bitmap.height); + assert_eq!(bitmap.rgba.len(), bitmap.width as usize * bitmap.height as usize * 4); + let mut opaque = 0; + for px in bitmap.rgba.chunks_exact(4) { + assert!(px[0] <= px[3] && px[1] <= px[3] && px[2] <= px[3], "premultiplied: {px:?}"); + opaque += usize::from(px[3] == 255); + } + assert!(opaque > 20, "the glyph draws something: {opaque} opaque pixels"); + // A bigger strike is scaled to a bigger image, and a smaller one to a smaller image. + let small = face.color_bitmap(gid, &no_coords(), 16.0); + let large = face.color_bitmap(gid, &no_coords(), 64.0); + if let (Some(small), Some(large)) = (small, large) { + assert!(small.width < bitmap.width && bitmap.width < large.width, "{} {} {}", small.width, bitmap.width, large.width); + } +} + +#[test] +fn emoji_in_plain_text_draw_from_a_colour_face() { + // The style's own face (DejaVu Sans on Linux) or a symbol font among the fallbacks (Windows has + // one) draws U+1F600 in one colour: the emoji face comes first for it. + if emoji_face().is_none() { + return; + } + let l = layout(&TextData::new("Hi \u{1F600}"), None, None); + assert!(l.glyphs.iter().any(|g| g.color(1.0).is_some()), "the emoji has colour ink"); +} + +#[test] +fn glyphs_place_their_ink_at_a_scale() { + let Some(face) = emoji_face() else { return }; + let mut t = TextData::new("\u{1F600}"); + (t.style.font_family, t.style.font_style) = (face.family.clone(), face.style.clone()); + t.style.font_size = 32.0; + let l = layout(&t, None, None); + let Some(g) = l.glyphs.first() else { return }; + // Zoomed 2:1 the glyph asks for a 64 px raster and places it in layout coordinates. + let Some(ColorInk::Bitmap(bitmap, place)) = g.color(2.0) else { + assert!(g.color(2.0).is_some(), "the emoji glyph has ink"); + return; + }; + // Some emoji faces draw wider than the em (Noto Color Emoji's strikes are about 1.25 em). + assert!(bitmap.width >= 32 && bitmap.width <= 96, "{} px at 2:1", bitmap.width); + let corner = place * figcraft_geom::Point::new(0.0, 0.0); + assert!(corner.x >= g.x - g.size && corner.x <= g.x + g.size, "x {} vs pen {}", corner.x, g.x); + assert!(corner.y >= g.y - g.size && corner.y <= g.y + g.size, "y {} vs baseline {}", corner.y, g.y); + // Past the raster cap the image stops growing (a canvas zoomed far in). + let Some(ColorInk::Bitmap(huge, _)) = g.color(1000.0) else { return }; + assert!(f64::from(huge.width) <= MAX_PX_PER_EM + 1.0, "{} px", huge.width); +} + +#[test] +fn text_without_colour_ink_keeps_its_outlines() { + // Faces without colour tables answer "no ink" without touching the font again, so plain text + // (the whole canvas, most of the time) pays one flag per glyph. + let Some(face) = crate::fontdb::find_face("Inter", "Regular") else { return }; + if face.has_color() { + return; + } + let gid = face.glyph_for('A'); + assert!(gid != 0, "Inter draws A"); + assert!(face.color_layers(gid).is_none() && face.color_bitmap(gid, &no_coords(), 32.0).is_none()); +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 2c584ebc6..cd9d53597 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -11,6 +11,8 @@ use skrifa::raw::{FileRef, TableProvider}; use skrifa::string::StringId; use skrifa::{GlyphId, MetadataProvider}; +use crate::color::{ColorLayer, Rasters}; + /// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`). pub struct CraftFont { pub family: &'static str, @@ -107,6 +109,11 @@ pub struct Face { charmap: HashMap, outlines: Mutex>>, advances: Mutex>, + /// Colour glyph tables (see [`crate::color`]): bitmap strikes and/or COLR layers. + pub(crate) bitmap_strikes: bool, + pub(crate) colr: bool, + pub(crate) rasters: Mutex, + pub(crate) layers: Mutex>>>>, } impl std::fmt::Debug for Face { @@ -139,6 +146,9 @@ impl Face { gsub_features.dedup(); let charmap: HashMap = f.charmap().mappings().map(|(c, g)| (c, g.to_u32())).collect(); let (axes, instances) = variation_info(&f); + let has_table = |tag: &[u8; 4]| f.data_for_tag(skrifa::Tag::new(tag)).is_some(); + let bitmap_strikes = has_table(b"sbix") || has_table(b"CBDT") || has_table(b"EBDT"); + let colr = has_table(b"COLR"); let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06)); let strike = m.strikeout.map(|d| f64::from(d.offset)).unwrap_or(ascent * 0.3); let weight = { @@ -169,6 +179,10 @@ impl Face { bytes, outlines: Mutex::new(HashMap::new()), advances: Mutex::new(HashMap::new()), + bitmap_strikes, + colr, + rasters: Mutex::new(Rasters::default()), + layers: Mutex::new(HashMap::new()), }) } @@ -613,7 +627,6 @@ impl FontDb { "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", - "Apple Color Emoji", "Noto Sans Arabic", "Apple Symbols", "Segoe UI Symbol", @@ -625,6 +638,19 @@ impl FontDb { out } + /// Colour emoji faces, for characters no other fallback covers. Kept out of + /// [`fallbacks`](Self::fallbacks) because the files are large (Apple Color Emoji is ~180 MB) + /// and only texts that actually show emoji need them; each family reads one face. + pub fn emoji_fallbacks(&mut self) -> Vec> { + let mut out = Vec::new(); + for fam in ["Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"] { + if let Some(f) = self.find_one(fam, "Regular") { + out.push(f); + } + } + out + } + pub fn face_count(&self) -> usize { self.faces.len() } @@ -670,14 +696,27 @@ fn system_font_dirs() -> Vec { { v.push("/usr/share/fonts".into()); v.push("/usr/local/share/fonts".into()); - if let Some(h) = std::env::var_os("HOME") { - v.push(std::path::PathBuf::from(&h).join(".fonts")); - v.push(std::path::PathBuf::from(h).join(".local/share/fonts")); - } + v.extend(unix_user_font_dirs(std::env::var_os("HOME"), std::env::var_os("XDG_DATA_HOME"))); } v } +// Fontconfig's default configuration uses $XDG_DATA_HOME/fonts for user fonts. +// Ignore relative XDG paths, as required by the XDG Base Directory Specification. +#[cfg(all(unix, not(target_os = "macos")))] +fn unix_user_font_dirs(home: Option, data_home: Option) -> Vec { + let home = home.map(std::path::PathBuf::from); + let data = data_home.map(std::path::PathBuf::from).filter(|p| p.is_absolute()).or_else(|| home.as_ref().map(|h| h.join(".local/share"))); + let mut dirs = Vec::new(); + if let Some(home) = home { + dirs.push(home.join(".fonts")); + } + if let Some(data) = data { + dirs.push(data.join("fonts")); + } + dirs +} + fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap)>) { if depth > 6 { return; @@ -699,8 +738,10 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap Option> { Some(buf) } -/// (index, family, style) of each outline face (one we can draw) in a font file. +/// (index, family, style) of each face in a font file: outline fonts and colour fonts (Apple Color +/// Emoji draws most emoji from `sbix`, Noto Color Emoji has no outlines at all). fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { let count = match FileRef::new(bytes) { Ok(FileRef::Collection(c)) => c.len(), @@ -753,7 +795,10 @@ fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { (0..count.min(64)) .filter_map(|i| { let f = skrifa::FontRef::from_index(bytes, i).ok()?; - [b"glyf", b"CFF ", b"CFF2"].iter().any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some()).then_some(())?; + [b"glyf", b"CFF ", b"CFF2", b"sbix", b"CBDT", b"EBDT", b"COLR"] + .iter() + .any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some()) + .then_some(())?; let (family, style) = names(&f); let family = family.filter(|f| !f.starts_with('.'))?; Some((i, family, style.unwrap_or_else(|| "Regular".into()))) diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index b9add9c2f..466298667 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -67,3 +67,29 @@ fn the_scan_reads_names_like_a_whole_file_read() { assert_eq!(faces_in(&index_bytes(&p, whole.len()).unwrap()), faces_in(&whole), "{}", p.display()); } } + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn user_font_dirs_follow_xdg_data_home() { + use std::ffi::OsString; + use std::path::PathBuf; + let dirs = |home: Option<&str>, data: Option<&str>| unix_user_font_dirs(home.map(OsString::from), data.map(OsString::from)); + let paths = |items: &[&str]| items.iter().map(PathBuf::from).collect::>(); + assert_eq!(dirs(Some("/home/user"), Some("/custom user data")), paths(&["/home/user/.fonts", "/custom user data/fonts"])); + for data in [None, Some(""), Some("relative/data")] { + assert_eq!(dirs(Some("/home/user"), data), paths(&["/home/user/.fonts", "/home/user/.local/share/fonts"])); + } + assert_eq!(dirs(None, Some("/custom-data")), paths(&["/custom-data/fonts"])); + assert_eq!(dirs(None, None), paths(&[])); + assert_eq!(dirs(None, Some("relative")), paths(&[])); +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn user_font_dirs_preserve_non_utf8_paths() { + use std::ffi::OsString; + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + let dirs = unix_user_font_dirs(Some(OsString::from_vec(b"/home/\xff".to_vec())), Some(OsString::from_vec(b"/data/\xfe".to_vec()))); + assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts"); + assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts"); +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 110c75898..836fe6a72 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -10,6 +10,7 @@ use figcraft_doc::{ use figcraft_geom::{Affine, BezPath, Rect}; use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation}; +use crate::color::{ColorInk, MAX_PX_PER_EM}; use crate::fontdb::{Coords, Face, db, no_coords}; /// A positioned glyph (pen position on the baseline, layout coordinates, y down). @@ -37,11 +38,34 @@ pub struct Glyph { impl Glyph { /// The glyph outline placed in layout coordinates. pub fn path(&self) -> BezPath { + self.layer_path(self.gid) + } + + /// The outline of one of the face's glyphs (a COLR layer), placed like this glyph. + pub fn layer_path(&self, gid: u32) -> BezPath { let s = self.size / self.face.upem; - let mut p = (*self.face.outline_at(self.gid, &self.coords)).clone(); + let mut p = (*self.face.outline_at(gid, &self.coords)).clone(); p.apply_affine(self.warp * Affine::new([s, 0.0, 0.0, s, self.x, self.y])); p } + + /// The glyph's colour ink at `scale` device pixels per layout unit, placed in layout + /// coordinates (a bitmap's pixels map through the affine; COLR layers are drawn like the + /// outline). `None` — the usual case — means the outline is what draws. + pub fn color(&self, scale: f64) -> Option { + if !scale.is_finite() || scale <= 0.0 { + return None; + } + if let Some(layers) = self.face.color_layers(self.gid) { + return Some(ColorInk::Layers(layers)); + } + // Whole device pixels per em: rasters stay on the pixel grid and the cache stays small. + let px = (self.size * scale).round().clamp(1.0, MAX_PX_PER_EM); + let bitmap = self.face.color_bitmap(self.gid, &self.coords, px)?; + let s = px / self.size; + let place = self.warp * Affine::translate((self.x + bitmap.left / s, self.y + bitmap.top / s)) * Affine::scale(1.0 / s); + Some(ColorInk::Bitmap(bitmap, place)) + } } #[derive(Clone, Debug)] @@ -299,11 +323,16 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) span_style.push(idx); } let mut dbw = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); - let fallbacks = dbw.fallbacks(); + let mut fallbacks = dbw.fallbacks(); let mut faces: Vec>> = Vec::with_capacity(styles.len()); for s in &styles { faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face())); } + // Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only + // for a text with emoji, or with characters nothing above covers. + let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); + let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() }; + fallbacks.extend(emoji.iter().cloned()); drop(dbw); // OpenType features the user toggled (global for the node). @@ -331,6 +360,14 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let mut seg_start = *start; while seg_start < *end { let face_for = |c: char| -> Arc { + // Emoji take a colour face before a face without colour glyphs (the style's own or a + // symbol font), which would draw them in one colour. + if is_emoji(c) + && !primary.has_color() + && let Some(f) = emoji.iter().find(|f| f.has_char(c)) + { + return f.clone(); + } if c == '\n' || primary.has_char(c) { return primary.clone(); } @@ -958,6 +995,13 @@ fn sanitize_size(s: f64) -> f64 { if s.is_finite() { s.clamp(0.5, 5000.0) } else { 12.0 } } +/// A character drawn as a colour emoji by default: flags and the pictograph blocks. +/// shortcut: the older symbols (U+2600–U+27BF) keep their text look even before U+FE0F; read the +/// emoji presentation data if they should turn colour too. +fn is_emoji(c: char) -> bool { + matches!(c, '\u{1F1E6}'..='\u{1F1FF}' | '\u{1F300}'..='\u{1FAFF}') +} + /// One run of chars shaped with a single face and style. struct Segment<'a> { start: usize, @@ -1213,7 +1257,8 @@ impl TextLayout { } let (x0, _) = self.caret(a.max(line.char_start)); let x1 = if b >= 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/lib.rs b/crates/text/src/lib.rs index 101d8bc55..27456d824 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -7,12 +7,14 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod breaks; +pub mod color; pub mod display; pub mod fontdb; mod layout; mod onpath; pub use breaks::grapheme_step; +pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs index 9f771b818..890b254de 100644 --- a/crates/text/src/tail_tests.rs +++ b/crates/text/src/tail_tests.rs @@ -130,14 +130,23 @@ fn hanging_punctuation_outside_the_aligned_edge() { t.auto_resize = TextAutoResize::Height; let Some(plain) = laid(&t) else { return }; let open = plain.glyphs[0].advance; + // Kerning may draw a glyph off its pen position (an x offset, e.g. Helvetica Neue's "re"), so + // each hanging line is checked against the same line set flush: every glyph moves by exactly + // the hanging mark's advance, which puts the letters' pen edge on the box edge. + let moved = |a: &TextLayout, b: &TextLayout, by: f64| { + a.glyphs.len() == b.glyphs.len() && a.glyphs.iter().zip(&b.glyphs).all(|(a, b)| (a.x - b.x - by).abs() < EPS) + }; t.hanging_punctuation = true; let l = laid(&t).unwrap(); - assert!((l.glyphs[0].x + open).abs() < EPS && l.glyphs[1].x.abs() < EPS, "left: the quote hangs, Q starts the line"); + assert!(plain.glyphs[0].x.abs() < EPS && moved(&l, &plain, -open), "left: the quote hangs, Q starts the line"); t.align_h = TextAlignH::Right; + t.hanging_punctuation = false; + let flush = laid(&t).unwrap(); + t.hanging_punctuation = true; let l = laid(&t).unwrap(); - let last = l.glyphs.last().unwrap(); - let before = &l.glyphs[l.glyphs.len() - 2]; - assert!((before.x + before.advance - 400.0).abs() < EPS && last.x >= 400.0 - EPS, "right: the full stop hangs past the edge"); + let stop = l.glyphs.last().unwrap().advance; + let edge = flush.lines[0].x + flush.lines[0].width; + assert!((edge - 400.0).abs() < EPS && moved(&l, &flush, stop), "right: the full stop hangs past the edge"); t.align_h = TextAlignH::Center; let (a, b) = (laid(&t).unwrap(), { t.hanging_punctuation = false; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 526ae0b03..e34f0110f 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/Cargo.toml b/crates/ui-egui/Cargo.toml index b14c04f21..65a3592d8 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,7 @@ log = { workspace = true } base64 = { workspace = true } image = { workspace = true } egui_extras = { workspace = true } +web-time = "1.1.0" [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 368ea6267..d5a4d3f08 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -71,7 +71,7 @@ fn locales() -> String { o } -/// The rows of one catalog table: `"key" = "text"` or `"key" = { one = "…", other = "…" }`. +/// The rows of one catalog table: strings or plural forms (`other` required). fn table(o: &mut String, path: &std::path::Path, t: Option<&toml::Value>) { let Some(t) = t.and_then(toml::Value::as_table) else { return }; // Sorted by key (byte order), as the lookup's binary search needs. @@ -81,8 +81,16 @@ fn table(o: &mut String, path: &std::path::Path, t: Option<&toml::Value>) { let s = |k: &str| v.get(k).and_then(toml::Value::as_str); match (v.as_str(), s("one"), s("other")) { (Some(text), ..) => _ = writeln!(o, " ({k:?}, Text::Plain({text:?})),"), - (None, one, Some(other)) => _ = writeln!(o, " ({k:?}, Text::Plural({}, {other:?})),", name(one)), - _ => println!("cargo::warning={}: `{k}` is neither a string nor {{ one, other }}; left out", path.display()), + (None, one, Some(other)) => { + _ = writeln!( + o, + " ({k:?}, Text::Plural {{ one: {}, few: {}, many: {}, other: {other:?} }}),", + name(one), + name(s("few")), + name(s("many")), + ); + } + _ => println!("cargo::warning={}: `{k}` is neither a string nor plural forms with `other`; left out", path.display()), } } } diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml new file mode 100644 index 000000000..aba0bec72 --- /dev/null +++ b/crates/ui-egui/locales/cs.toml @@ -0,0 +1,2177 @@ +# Czech interface catalog. Machine-drafted; awaiting native review. +# Plurals use count-neutral other forms for Czech 0, 2–4 and 5+. +[meta] +name = "Čeština" +status = "draft — needs native review" + +[ui] +"% of all commits" = "% všech commitů" +"(no longer published)" = "(již nepublikováno)" +"({n} warnings: some entries were skipped)" = { one = "({n} varování: některé položky byly přeskočeny)", other = "(Varování: {n}; některé položky byly přeskočeny)" } +"+ Create variable" = "+ Vytvořit proměnnou" +".fig (experimental)" = ".fig (experimentální)" +"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}, commity: {commits}, +{added} / −{deleted} řádků (Δ {delta}), +{bin_added} / −{bin_deleted} binárních souborů, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekce tohoto souboru s těmito režimy a proměnnými: přepište jejich hodnoty pro jednotlivé režimy a přidejte vlastní proměnné" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Samostatná vrstva se zarovná ke svému rámci: vyberte více vrstev nebo jednu v rámci" +"A responsive product page: hero, features, figures, a quote and a footer" = "Responzivní produktová stránka: úvodní blok, funkce, čísla, citace a zápatí" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Pružina se pohybuje až do ustálení: délku určuje její hmotnost, tuhost a tlumení" +"A title, an agenda, a big figure, a chart and a closing slide" = "Titulní snímek, program, výrazné číslo, graf a závěrečný snímek" +"A to Z" = "Od A do Z" +"About" = "O aplikaci" +"About FigCraft" = "O aplikaci FigCraft" +"About this server" = "O tomto serveru" +"Action" = "Akce" +"Actions" = "Akce" +"Actions for {name}" = "Akce pro {name}" +"Actions…" = "Akce…" +"Active" = "Aktivní" +"Actual size (100%)" = "Skutečná velikost (100 %)" +"Add a category" = "Přidat kategorii" +"Add a comment" = "Přidat komentář" +"Add a description" = "Přidat popis" +"Add a frame (F) or a shape (R) to get started" = "Začněte přidáním rámce (F) nebo tvaru (R)" +"Add a layer or component to tile first" = "Nejprve přidejte vrstvu nebo komponentu k opakování" +"Add a note for developers" = "Přidat poznámku pro vývojáře" +"Add action" = "Přidat akci" +"Add actions for when no condition holds" = "Přidat akce pro případ, kdy neplatí žádná podmínka" +"Add an annotation for developers" = "Přidat anotaci pro vývojáře" +"Add another condition" = "Přidat další podmínku" +"Add auto layout" = "Přidat automatické rozvržení" +"Add background behind overlay" = "Přidat pozadí za překryv" +"Add content to this slot" = "Přidat obsah do tohoto slotu" +"Add dev resource" = "Přidat zdroj pro vývojáře" +"Add effect" = "Přidat efekt" +"Add export setting" = "Přidat nastavení exportu" +"Add fill" = "Přidat výplň" +"Add interaction" = "Přidat interakci" +"Add layout grid" = "Přidat mřížku rozvržení" +"Add library" = "Přidat knihovnu" +"Add library file…" = "Přidat soubor knihovny…" +"Add max height" = "Přidat maximální výšku" +"Add max width" = "Přidat maximální šířku" +"Add min height" = "Přidat minimální výšku" +"Add min width" = "Přidat minimální šířku" +"Add mode" = "Přidat režim" +"Add new page" = "Přidat novou stránku" +"Add or update collections from a variables JSON" = "Přidat nebo aktualizovat kolekce ze souboru JSON s proměnnými" +"Add properties to make instances configurable" = "Přidejte vlastnosti pro nastavení instancí" +"Add reaction" = "Přidat reakci" +"Add starting point" = "Přidat počáteční bod" +"Add stroke" = "Přidat tah" +"Add variant" = "Přidat variantu" +"Add variant with a new value" = "Přidat variantu s novou hodnotou" +"Added" = "Přidáno" +"Added the library \"{name}\"" = "Přidána knihovna „{name}“" +"Adjust" = "Upravit" +"Adjustment presets" = "Předvolby úprav" +"Advanced" = "Pokročilé" +"Advanced stroke" = "Pokročilé nastavení tahu" +"After delay" = "Po prodlevě" +"After delay {ms}ms" = "Po prodlevě {ms} ms" +"Align" = "Zarovnat" +"Align bottom" = "Zarovnat dolů" +"Align center" = "Zarovnat na střed" +"Align horizontal centers" = "Zarovnat vodorovné středy" +"Align left" = "Zarovnat vlevo" +"Align middle" = "Zarovnat doprostřed" +"Align right" = "Zarovnat vpravo" +"Align text baselines" = "Zarovnat účaří textu" +"Align top" = "Zarovnat nahoru" +"Align vertical centers" = "Zarovnat svislé středy" +"Alignment" = "Zarovnání" +"All" = "Vše" +"All categories" = "Všechny kategorie" +"All comments" = "Všechny komentáře" +"All components" = "Všechny komponenty" +"All fonts" = "Všechna písma" +"All supported properties" = "Všechny podporované vlastnosti" +"All variables" = "Všechny proměnné" +"Alpha" = "Alfa" +"Alphabetical" = "Abecedně" +"Alt+drag" = "Alt+tažení" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Analytický přehled na mřížce: čísla, vektorové grafy a tabulka" +"An invite link that opens FigCraft and joins" = "Odkaz s pozvánkou, který otevře FigCraft a připojí k relaci" +"Anchor" = "Kotva" +"Android" = "Android" +"Angular" = "Úhlový" +"Animation" = "Animace" +"Annotations" = "Anotace" +"Annotations shown on the canvas" = "Anotace zobrazené na plátně" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Jiný server nebo server vyžadující klíč. Výchozí server se nastavuje v předvolbách." +"Any status" = "Libovolný stav" +"Anyone on your network with this code can edit the file, while this app stays open." = "Kdokoli ve vaší síti s tímto kódem může soubor upravovat, dokud tato aplikace zůstane otevřená." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Kdokoli s odkazem pro úpravy může soubor měnit; odkaz pro prohlížení umožňuje pouze prohlížení. Odkazy obsahují tajné údaje: sdílejte je jako hesla. Příkaz `figcraft-server links` vytváří další odkazy a odvolává je." +"Anyone with this code can edit the file." = "Kdokoli s tímto kódem může soubor upravovat." +"Anyone with this code can look at the file, not change it." = "Kdokoli s tímto kódem může soubor prohlížet, ale nemůže ho měnit." +"Appearance" = "Vzhled" +"Apply a component property" = "Použít vlastnost komponenty" +"Apply variable" = "Použít proměnnou" +"Apply variable to text" = "Použít proměnnou na text" +"Apply variable to visibility" = "Použít proměnnou na viditelnost" +"Apps" = "Aplikace" +"Apr" = "dubna" +"Arc" = "Oblouk" +"Arc ratio" = "Poměr oblouku" +"Arc start" = "Začátek oblouku" +"Arc sweep" = "Rozsah oblouku" +"Archive" = "Archivovat" +"Archived" = "Archivováno" +"Arrange" = "Uspořádat" +"Arrow" = "Šipka" +"As typed" = "Podle zadání" +"Asking the server…" = "Dotazování serveru…" +"Assets" = "Zdroje" +"Aug" = "srpna" +"Auto" = "Automaticky" +"Auto (space around)" = "Automaticky (mezery kolem)" +"Auto (space between)" = "Automaticky (mezery mezi)" +"Auto (space evenly)" = "Automaticky (stejné mezery)" +"Auto ({mode})" = "Automaticky ({mode})" +"Auto columns" = "Automatické sloupce" +"Auto gap" = "Automatický rozestup" +"Auto height" = "Automatická výška" +"Auto layout" = "Automatické rozvržení" +"Auto rows" = "Automatické řádky" +"Auto width" = "Automatická šířka" +"Back to the FigCraft server" = "Zpět na server FigCraft" +"Back to the font's value" = "Zpět na hodnotu písma" +"Back" = "Zpět" +"Back to Ready for dev" = "Zpět do stavu Připraveno pro vývoj" +"Back to Ready for dev: the next change compares with how it is now" = "Zpět do stavu Připraveno pro vývoj: další změna se porovná s aktuálním stavem" +"Back to assets" = "Zpět ke zdrojům" +"Back to current" = "Zpět na aktuální" +"Back to styles" = "Zpět ke stylům" +"Back to the canvas from a focused region" = "Zpět na plátno ze zaměřené oblasti" +"Back to the current version" = "Zpět na aktuální verzi" +"Back to the values the font style implies" = "Zpět na hodnoty dané řezem písma" +"Background" = "Pozadí" +"Background blur" = "Rozostření pozadí" +"Basics" = "Základy" +"Before" = "Před" +"Bend" = "Ohnout" +"Bevel" = "Zkosený" +"Binary assets added" = "Přidané binární soubory" +"Binary assets removed" = "Odstraněné binární soubory" +"Black & white" = "Černobílé" +"Bleed" = "Spadávka" +"Bleed marks" = "Značky spadávky" +"Bleed marks need a bleed" = "Značky spadávky vyžadují spadávku" +"Blend" = "Prolnutí" +"Blend mode" = "Režim prolnutí" +"Blue" = "Modrá" +"Blur" = "Rozostření" +"Boolean" = "Logická hodnota" +"Boolean groups" = "Booleovské skupiny" +"Both directions" = "Oba směry" +"Bottom" = "Dole" +"Bottom left radius" = "Poloměr levého dolního rohu" +"Bottom right radius" = "Poloměr pravého dolního rohu" +"Branch" = "Větev" +"Branch archived" = "Větev archivována" +"Branch merged" = "Větev sloučena" +"Branch name" = "Název větve" +"Branch of {file}" = "Větev souboru {file}" +"Branch of {file} · merged or archived" = "Větev souboru {file} · sloučená nebo archivovaná" +"Branch updated from main" = "Větev aktualizována z hlavního souboru" +"Branch {name}" = "Větev {name}" +"Branches" = "Větve" +"Bound to {name}. Click to detach the variable." = "Navázáno na {name}. Kliknutím odpojíte proměnnou." +"Bring forward" = "Přenést o úroveň výš" +"Bring the main file's changes into the branch" = "Přenést změny hlavního souboru do větve" +"Bring to front" = "Přenést do popředí" +"Bristle" = "Štětiny" +"Browse" = "Procházet" +"Brush" = "Štětec" +"Brush rotation jitter" = "Náhodná odchylka natočení štětce" +"Brush scatter" = "Rozptyl štětce" +"Brush size jitter" = "Náhodná odchylka velikosti štětce" +"Brush spacing" = "Rozestup štětce" +"Bulleted list" = "Odrážkový seznam" +"C or Shift+C" = "C nebo Shift+C" +"C or ⇧C" = "C nebo ⇧C" +"CMYK colour" = "Barva CMYK" +"Can view" = "Může prohlížet" +"Checking the server…" = "Kontrola serveru…" +"Clear from this list" = "Vymazat z tohoto seznamu" +"Code or invite link" = "Kód nebo odkaz s pozvánkou" +"Color profile: {name}" = "Barevný profil: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Barevné a textové styly, proměnné pro světlý a tmavý režim, tlačítka, vstupní pole a karty" +"CSS custom properties…" = "Vlastní vlastnosti CSS…" +"Can edit" = "Může upravovat" +"Can't read this library: {e}" = "Tuto knihovnu nelze přečíst: {e}" +"Cancel" = "Zrušit" +"Canvas" = "Plátno" +"Canvas stacking" = "Pořadí na plátně" +"Cap height to baseline" = "Od horní hrany verzálek po účaří" +"Case" = "Velikost písmen" +"Case-sensitive forms" = "Tvary podle velikosti písmen" +"Categories" = "Kategorie" +"Center" = "Střed" +"Change alias" = "Změnit alias" +"Change resolved" = "Změna vyřešena" +"Change style" = "Změnit styl" +"Change to" = "Změnit na" +"Change to needs a layer inside a variant" = "Akce Změnit na vyžaduje vrstvu uvnitř varianty" +"Changed" = "Změněno" +"Changed on both sides: keep one" = "Změněno na obou stranách: vyberte jednu" +"Changes on main" = "Změny v hlavním souboru" +"Changes on the branch" = "Změny ve větvi" +"Changes unknown" = "Změny nejsou známy" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Pro propojení této komponenty s jejím kódem vyberte nahoře React, Leptos, SwiftUI nebo Compose." +"Choose another profile to change it." = "Pro změnu vyberte jiný profil." +"Choose collection" = "Vybrat kolekci" +"Choose image" = "Vybrat obrázek" +"Choose mode" = "Vybrat režim" +"Choose variable" = "Vybrat proměnnou" +"Circle" = "Kruh" +"Clear recent" = "Vymazat nedávné" +"Click + to replace mixed content" = "Kliknutím na + nahradíte smíšený obsah" +"Click anywhere on the canvas to leave one." = "Kliknutím kamkoli na plátno přidáte komentář." +"Click or drag to merge · {alt} removes" = "Kliknutím nebo tažením sloučíte · {alt} odečítá" +"Click or drag to remove · {alt} merges" = "Kliknutím nebo tažením odeberete · {alt} slučuje" +"Click to record a key" = "Kliknutím zaznamenáte klávesu" +"Clip content" = "Oříznout obsah" +"Clip to shape" = "Oříznout podle tvaru" +"Close" = "Zavřít" +"Close overlay" = "Zavřít překryv" +"Close preview" = "Zavřít náhled" +"Close tab" = "Zavřít kartu" +"Close version history" = "Zavřít historii verzí" +"Close when clicking outside" = "Zavřít při kliknutí mimo" +"Close without saving" = "Zavřít bez uložení" +"Code" = "Kód" +"Code syntax" = "Syntaxe kódu" +"Collapse" = "Sbalit" +"Collapse panel" = "Sbalit panel" +"Collection" = "Kolekce" +"Collections" = "Kolekce" +"Color" = "Barva" +"Color burn" = "Ztmavit barvy" +"Color dodge" = "Zesvětlit barvy" +"Color format" = "Formát barvy" +"Color profile" = "Barevný profil" +"Color styles" = "Barevné styly" +"Color values in this file are in this color space." = "Hodnoty barev v tomto souboru jsou v tomto barevném prostoru." +"Color variables" = "Barevné proměnné" +"Colour" = "Barva" +"Column" = "Sloupec" +"Column gap" = "Mezera mezi sloupci" +"Column sizes" = "Velikosti sloupců" +"Column span" = "Rozpětí přes sloupce" +"Columns" = "Sloupce" +"Columns {count}" = "Sloupce: {count}" +"Comment" = "Komentář" +"Comments" = "Komentáře" +"Commits" = "Commity" +"Company" = "Společnost" +"Compare changes" = "Porovnat změny" +"Completed" = "Dokončeno" +"Component" = "Komponenta" +"Component name" = "Název komponenty" +"Component property" = "Vlastnost komponenty" +"Component set" = "Sada komponent" +"Component sets" = "Sady komponent" +"Components" = "Komponenty" +"Conditional" = "Podmíněná akce" +"Confetti" = "Konfety" +"Conflicts" = "Konflikty" +"Connect to code" = "Propojit s kódem" +"Connected" = "Připojeno" +"Connected to code: {from}" = "Propojeno s kódem: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Spojení ztraceno, opětovné připojování (pokus {attempt})…" +"Constrain proportions" = "Zachovat poměr stran" +"Constraints" = "Omezení" +"Content" = "Obsah" +"Contents hidden" = "Obsah skryt" +"Contents only" = "Pouze obsah" +"Contextual alternates" = "Kontextové alternativy" +"Contrast" = "Kontrast" +"Contributors" = "Přispěvatelé" +"Contributors: {people} · Commits: {commits}" = "Přispěvatelé: {people} · Commity: {commits}" +"Controller" = "Ovladač" +"Cool" = "Studené" +"Copied as CSS" = "Zkopírováno jako CSS" +"Copied as PNG" = "Zkopírováno jako PNG" +"Copied as SVG" = "Zkopírováno jako SVG" +"Copied as text" = "Zkopírováno jako text" +"Copied {value}" = "Zkopírováno: {value}" +"Copy" = "Kopírovat" +"Copy as" = "Kopírovat jako" +"Copy as CSS" = "Kopírovat jako CSS" +"Copy as PNG" = "Kopírovat jako PNG" +"Copy as SVG" = "Kopírovat jako SVG" +"Copy code" = "Kopírovat kód" +"Copy link" = "Kopírovat odkaz" +"Copy the {label}" = "Kopírovat: {label}" +"Copy {value}" = "Kopírovat: {value}" +"Corner radius" = "Poloměr rohu" +"Corner smoothing" = "Vyhlazení rohů" +"Couldn't reach {server}: showing what this computer remembers." = "Server {server} není dostupný: zobrazuje se stav uložený v tomto počítači." +"Count" = "Počet" +"Create" = "Vytvořit" +"Create a variable collection first" = "Nejprve vytvořte kolekci proměnných" +"Create a variable first" = "Nejprve vytvořte proměnnou" +"Create alias" = "Vytvořit alias" +"Create branch" = "Vytvořit větev" +"Create collection" = "Vytvořit kolekci" +"Create component" = "Vytvořit komponentu" +"Create component property" = "Vytvořit vlastnost komponenty" +"Create components, styles or variables to publish them as a library." = "Vytvořte komponenty, styly nebo proměnné a publikujte je jako knihovnu." +"Create property…" = "Vytvořit vlastnost…" +"Create style" = "Vytvořit styl" +"Create style from the selected text" = "Vytvořit styl z vybraného textu" +"Created" = "Vytvořeno" +"Crop" = "Oříznout" +"Crop marks" = "Ořezové značky" +"Ctrl+arrows" = "Ctrl+šipky" +"Ctrl+click" = "Ctrl+kliknutí" +"Ctrl+scroll" = "Ctrl+posouvání" +"Current name" = "Aktuální název" +"Current version" = "Aktuální verze" +"Cursor chat" = "Chat u kurzoru" +"Custom" = "Vlastní" +"Cut" = "Vyjmout" +"Dark" = "Tmavý" +"Darken" = "Ztmavit" +"Dash" = "Čárkování" +"Dashboard" = "Přehled" +"Date created" = "Datum vytvoření" +"Dec" = "prosince" +"Decoration style" = "Styl dekorace" +"Decoration" = "Dekorace" +"Deep select" = "Hloubkový výběr" +"Default" = "Výchozí" +"Default content" = "Výchozí obsah" +"Default value (the top-left variant)" = "Výchozí hodnota (varianta vlevo nahoře)" +"Delay" = "Prodleva" +"Delete" = "Smazat" +"Delete category (its annotations stay)" = "Smazat kategorii (její anotace zůstanou)" +"Delete collection" = "Smazat kolekci" +"Delete group" = "Smazat skupinu" +"Delete mode" = "Smazat režim" +"Delete page" = "Smazat stránku" +"Delete preset" = "Smazat předvolbu" +"Delete property" = "Smazat vlastnost" +"Delete the style (layers keep their values)" = "Smazat styl (vrstvy si zachovají hodnoty)" +"Delete thread" = "Smazat vlákno" +"Delete variable" = "Smazat proměnnou" +"Delete “{name}”? This can't be undone." = "Smazat „{name}“? Tuto akci nelze vrátit zpět." +"Density" = "Hustota" +"Depth" = "Hloubka" +"Describe how and when to use this component" = "Popište, jak a kdy tuto komponentu používat" +"Describe what changed" = "Popište změny" +"Description" = "Popis" +"Description (optional)" = "Popis (nepovinný)" +"Design" = "Návrh" +"Design system starter" = "Základ design systému" +"Desktop" = "Počítač" +"Destination" = "Cíl" +"Detach alias" = "Odpojit alias" +"Detach instance" = "Odpojit instanci" +"Detach property" = "Odpojit vlastnost" +"Detach style" = "Odpojit styl" +"Detach styles" = "Odpojit styly" +"Detach variable" = "Odpojit proměnnou" +"Details" = "Podrobnosti" +"Dev Mode" = "Režim pro vývojáře" +"Dev resources" = "Zdroje pro vývojáře" +"Development" = "Vývoj" +"Device" = "Zařízení" +"Device at 100%" = "Zařízení ve 100 %" +"Diamond" = "Kosočtverec" +"Difference" = "Rozdíl" +"Dimensions" = "Rozměry" +"Direction" = "Směr" +"Disconnect from code" = "Odpojit od kódu" +"Dispersion" = "Rozptyl" +"Display name" = "Zobrazované jméno" +"Dissolve" = "Prolnutí snímků" +"Distribute and tidy" = "Rozmístit a uspořádat" +"Distribute horizontal spacing" = "Rozmístit vodorovné rozestupy" +"Distribute vertical spacing" = "Rozmístit svislé rozestupy" +"Divider" = "Oddělovač" +"Don't truncate" = "Nezkracovat" +"Done" = "Hotovo" +"Done editing" = "Dokončit úpravy" +"Dots" = "Tečky" +"Dotted" = "Tečkovaný" +"Down" = "Dolů" +"Drafts" = "Koncepty" +"Drag the stroke to add a width point" = "Tažením tahu přidáte bod šířky" +"Draw" = "Kreslení" +"Draw a frame" = "Nakreslit rámec" +"Drawing tools" = "Kreslicí nástroje" +"Drop shadow" = "Vržený stín" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Dvojí licence MIT OR Apache-2.0. Vytvořil tým ArtCraft a komunita." +"Duo" = "Dvojtónové" +"Duplicate" = "Duplikovat" +"Duplicate by dragging" = "Duplikovat tažením" +"Duplicate mode" = "Duplikovat režim" +"Duplicate page" = "Duplikovat stránku" +"Edit" = "Upravit" +"Edit as an expression" = "Upravit jako výraz" +"Edit colour" = "Upravit barvu" +"Edit comment" = "Upravit komentář" +"Edit dev resource" = "Upravit zdroj pro vývojáře" +"Edit link" = "Odkaz pro úpravy" +"Edit link copied" = "Odkaz pro úpravy zkopírován" +"Edit style" = "Upravit styl" +"Edit variable" = "Upravit proměnnou" +"Edit with the builder" = "Upravit pomocí tvorby tvarů" +"Edited {when}" = "Upraveno {when}" +"Effect settings" = "Nastavení efektu" +"Effect styles" = "Styly efektů" +"Effects" = "Efekty" +"Ellipse" = "Elipsa" +"Ellipses" = "Elipsy" +"Else" = "Jinak" +"Else if" = "Jinak pokud" +"Embedded profile" = "Vložený profil" +"Empty" = "Prázdné" +"End" = "Konec" +"End point" = "Koncový bod" +"Ends the session; everyone keeps their copy" = "Ukončí relaci; každý si ponechá svou kopii" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Zadejte adresu serveru figcraft-server a stisknutím Procházet zobrazte nabízené pluginy." +"Enter to save · Esc to cancel" = "Enter uloží · Esc zruší" +"Eraser" = "Guma" +"Esc" = "Esc" +"Essential" = "Základní" +"Every conflict has a choice." = "Každý konflikt má vybrané řešení." +"Every top-level frame already starts a flow" = "Každý rámec nejvyšší úrovně již zahajuje tok" +"Exact opacity (4 then 5: 45%)" = "Přesné krytí (4 a pak 5: 45 %)" +"Exclude" = "Vyloučit" +"Excluded" = "Vyloučeno" +"Excluded from layout" = "Vyloučeno z rozvržení" +"Excludes" = "Vyloučení" +"Exclusion" = "Vylučovací prolnutí" +"Exit focus view (Esc)" = "Opustit zaměřený pohled (Esc)" +"Expand" = "Rozbalit" +"Export" = "Export" +"Export all" = "Exportovat vše" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Exportovat rámce a vrstvy jako PNG, JPG, SVG nebo PDF" +"Export page as PNG" = "Exportovat stránku jako PNG" +"Export settings" = "Nastavení exportu" +"Export variables" = "Exportovat proměnné" +"Export {name}" = "Exportovat {name}" +"Export {n} layers" = { one = "Exportovat {n} vrstvu", other = "Exportovat vrstvy: {n}" } +"Exported {file}" = "Exportován soubor {file}" +"Exported {name}" = "Exportováno: {name}" +"Exported {name}; {n} kinds of content were left out" = { one = "Exportováno: {name}; vynechán {n} druh obsahu", other = "Exportováno: {name}; vynechané druhy obsahu: {n}" } +"Exported {n} files" = { one = "Exportován {n} soubor", other = "Exportované soubory: {n}" } +"Export…" = "Exportovat…" +"Expose properties to instances" = "Zpřístupnit vlastnosti instancím" +"Exposure" = "Expozice" +"Expression" = "Výraz" +"Extend collection" = "Rozšířit kolekci" +"Extends \"{name}\": overridden values are marked" = "Rozšiřuje „{name}“: přepsané hodnoty jsou označeny" +"Eye" = "Oko" +"F" = "F" +"Fade" = "Vyblednutí" +"False" = "Nepravda" +"Feb" = "února" +"Fetching from the server…" = "Stahování ze serveru…" +"File" = "Soubor" +"File name" = "Název souboru" +"File not found" = "Soubor nenalezen" +"File published" = "Soubor publikován" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Soubory nemají koncové šifrování: provozovatel serveru je může číst." +"Files nobody opens for {n} days are deleted." = { one = "Soubory, které nikdo neotevře déle než {n} den, se smažou.", other = "Soubory, které nikdo neotevře déle než {n} d, se smažou." } +"Fill" = "Výplň" +"Fill (fr)" = "Vyplnit (fr)" +"Fill container" = "Vyplnit kontejner" +"Fill window" = "Vyplnit okno" +"Fills" = "Výplně" +"Fill|sizing" = "Vyplnit" +"Filter and sort" = "Filtrovat a řadit" +"Find" = "Najít" +"Find and replace" = "Najít a nahradit" +"Find layers" = "Najít vrstvy" +"First" = "První" +"First commit" = "První commit" +"First on top" = "První nahoře" +"Fit" = "Přizpůsobit" +"Fit device to window" = "Přizpůsobit zařízení oknu" +"Fit to contents" = "Přizpůsobit obsahu" +"Fit to window" = "Přizpůsobit oknu" +"Fit width" = "Přizpůsobit šířce" +"Fixed" = "Pevné" +"Fixed gap" = "Pevný rozestup" +"Fixed size" = "Pevná velikost" +"Flatten" = "Sloučit do vektoru" +"Flip" = "Převrátit" +"Flip horizontal" = "Převrátit vodorovně" +"Flip text to the other side" = "Převrátit text na druhou stranu" +"Flip vertical" = "Převrátit svisle" +"Flip width profile" = "Převrátit profil šířky" +"Flow starting point" = "Počáteční bod toku" +"Flow starting points" = "Počáteční body toků" +"Flows" = "Toky" +"Focus view" = "Zaměřený pohled" +"Fold autosaves" = "Sbalit automaticky uložené verze" +"Folder: click to collapse" = "Složka: kliknutím sbalíte" +"Folder: click to expand" = "Složka: kliknutím rozbalíte" +"Follow" = "Sledovat" +"Follow {name}" = "Sledovat: {name}" +"Following" = "Sledování" +"Following {name} · {key} to stop" = "Sledujete: {name} · {key} ukončí sledování" +"Font" = "Písmo" +"Font not installed" = "Písmo není nainstalováno" +"Font size" = "Velikost písma" +"Font style" = "Řez písma" +"Forget on this computer" = "Zapomenout v tomto počítači" +"Four screens of a trail app with components and a prototype flow" = "Čtyři obrazovky turistické aplikace s komponentami a tokem prototypu" +"Fractions" = "Zlomky" +"Frame" = "Rámec" +"Frame selection" = "Vytvořit rámec z výběru" +"Frame tools" = "Nástroje rámců" +"Frames" = "Rámce" +"From the library \"{name}\": read-only" = "Z knihovny „{name}“: jen pro čtení" +"From {name}" = "Z {name}" +"From {server}" = "Ze serveru {server}" +"Frost" = "Námraza" +"Gap" = "Rozestup" +"Gap between columns" = "Mezera mezi sloupci" +"Gap between items" = "Mezera mezi položkami" +"Gap between rows" = "Mezera mezi řádky" +"Getting started" = "Začínáme" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Zadejte adresu veřejného serveru nebo adresu serveru s klíčem (vypíše ho příkaz `figcraft-server key`)." +"Glass" = "Sklo" +"Go to main component" = "Přejít na hlavní komponentu" +"Go to {name}" = "Přejít na {name}" +"Grab bag" = "Směs" +"Gradient" = "Barevný přechod" +"Gray" = "Šedá" +"Green" = "Zelená" +"Greyscale components and two example screens to sketch a flow with" = "Komponenty v odstínech šedi a dvě ukázkové obrazovky pro náčrt toku" +"Grid" = "Mřížka" +"Grid ({size})" = "Mřížka ({size})" +"Grid styles" = "Styly mřížek" +"Grid {size}" = "Mřížka {size}" +"Group" = "Skupina" +"Group selection" = "Seskupit výběr" +"Groups" = "Skupiny" +"Gutter" = "Mezera" +"Hand tool" = "Nástroj ruka" +"Hanging list" = "Předsazený seznam" +"Hanging punctuation" = "Předsazená interpunkce" +"Hard light" = "Tvrdé světlo" +"Height" = "Výška" +"Help" = "Nápověda" +"Help and resources" = "Nápověda a zdroje" +"Help page" = "Stránka nápovědy" +"Here: {names}" = "Zde: {names}" +"Hidden" = "Skryté" +"Hide contents" = "Skrýt obsah" +"Hide from publishing" = "Skrýt při publikování" +"Hide section contents" = "Skrýt obsah sekce" +"Hide these tips (Getting started in the Help menu shows them again)" = "Skrýt tyto tipy (znovu je zobrazíte volbou Začínáme v nabídce Nápověda)" +"Hide when publishing" = "Skrýt při publikování" +"Highlight layers on hover" = "Zvýraznit vrstvy pod ukazatelem" +"Highlights" = "Světla" +"Hold Alt" = "Podržte Alt" +"Hold Alt+Z" = "Podržte Alt+Z" +"Hold Ctrl" = "Podržte Ctrl" +"Hold Z" = "Podržte Z" +"Hold ⌃ or ⌘" = "Podržte ⌃ nebo ⌘" +"Hold ⌥" = "Podržte ⌥" +"Hold ⌥Z" = "Podržte ⌥Z" +"Home" = "Domů" +"Horizontal" = "Vodorovně" +"Horizontal align" = "Vodorovné zarovnání" +"Horizontal hex" = "Vodorovný šestiúhelník" +"Horizontal padding" = "Vodorovné vnitřní odsazení" +"Horizontal spacing" = "Vodorovné rozestupy" +"Hosted by {operator}" = "Provozuje {operator}" +"Hover a layer to see its properties" = "Najeďte na vrstvu a zobrazte její vlastnosti" +"Hover other layers to measure the distance" = "Najeďte na jiné vrstvy a změřte vzdálenost" +"Hue" = "Odstín" +"Hug" = "Podle obsahu" +"Hug contents" = "Podle obsahu" +"Icon components on a 24 px grid with keylines and a size preview" = "Komponenty ikon na mřížce 24 px s pomocnými liniemi a náhledem velikosti" +"Icon set starter" = "Základ sady ikon" +"If" = "Pokud" +"If {condition}" = "Pokud {condition}" +"Ignore auto layout (absolute position)" = "Ignorovat automatické rozvržení (absolutní poloha)" +"Image" = "Obrázek" +"Import" = "Import" +"Import path" = "Cesta importu" +"Import plugin from manifest" = "Importovat plugin z manifestu" +"Import plugin from manifest…" = "Importovat plugin z manifestu…" +"Import variables" = "Importovat proměnné" +"Imported {name}" = "Importováno: {name}" +"Imported {n} new variables, updated {updated}" = { one = "Importována {n} nová proměnná, aktualizováno: {updated}", other = "Nově importované proměnné: {n}, aktualizované: {updated}" } +"Import…" = "Importovat…" +"In an instance, this comes from the main component" = "V instanci se toto přebírá z hlavní komponenty" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "V prohlížeči se můžete připojit k relacím hostovaným desktopovou aplikací a k souborům na serveru FigCraft. Stránka otevřená přes https může přistupovat k jiným počítačům pouze přes odkazy wss:// (server za TLS)." +"Include bounding box" = "Zahrnout ohraničující rámeček" +"Include id attribute" = "Zahrnout atribut id" +"Included" = "Zahrnuto" +"Included in layout" = "Zahrnuto do rozvržení" +"Individual corners" = "Jednotlivé rohy" +"Individual strokes" = "Jednotlivé tahy" +"Ink" = "Inkoust" +"Inner shadow" = "Vnitřní stín" +"Inside" = "Uvnitř" +"Inspect" = "Inspekce" +"Inspect the layer under the pointer: size, colours, typography" = "Prozkoumat vrstvu pod ukazatelem: velikost, barvy, typografii" +"Install plugin" = "Nainstalovat plugin" +"Install" = "Nainstalovat" +"Installed {name}" = "Nainstalováno: {name}" +"Installed" = "Nainstalované" +"Installing…" = "Instalace…" +"Instance" = "Instance" +"Instance properties" = "Vlastnosti instance" +"Instance swap" = "Výměna instance" +"Instance swap (no other component)" = "Výměna instance (žádná jiná komponenta)" +"Instances" = "Instance" +"Instant" = "Okamžitě" +"Interaction" = "Interakce" +"Interactions" = "Interakce" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Návrh rozhraní a prototypování. Svobodný nástroj s otevřeným zdrojovým kódem, napsaný od základu v jazyce Rust nezávisle na kódu jiných produktů. Součást sady ArtCraft Crafting Apps." +"Intersect" = "Průnik" +"Intersects" = "Průniky" +"Invite link" = "Odkaz s pozvánkou" +"Invite link copied" = "Odkaz s pozvánkou zkopírován" +"It asks permission to:" = "Žádá o oprávnění:" +"Jan" = "ledna" +"Join" = "Připojit se" +"Join code copied" = "Kód pro připojení zkopírován" +"Join code or invite link" = "Kód pro připojení nebo odkaz s pozvánkou" +"Join live session" = "Připojit se k živé relaci" +"Join us on Discord" = "Připojte se k nám na platformě Discord" +"Join with a code" = "Připojit se pomocí kódu" +"Joined" = "Připojeno" +"Joined {file}" = "Připojeno k souboru {file}" +"Joining…" = "Připojování…" +"Join|stroke" = "Spojení" +"Jul" = "července" +"Jun" = "června" +"Just now" = "Právě teď" +"Justify" = "Do bloku" +"Keep appearance" = "Zachovat vzhled" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachovat vzhled převede všechny hodnoty barev do profilu {profile}, aby barvy vypadaly stejně. Zachovat hodnoty ponechá čísla beze změny, takže se barvy posunou." +"Keep tool selected after use" = "Po použití ponechat nástroj vybraný" +"Keep values" = "Zachovat hodnoty" +"Key/Gamepad" = "Klávesa/ovladač" +"Key/Gamepad {key}" = "Klávesa/ovladač {key}" +"Keyboard shortcuts" = "Klávesové zkratky" +"Kits" = "Sady" +"Landing page" = "Vstupní stránka" +"Language" = "Jazyk" +"Lasso" = "Laso" +"Last" = "Poslední" +"Last change: {note}" = "Poslední změna: {note}" +"Last commit" = "Poslední commit" +"Last modified" = "Poslední úprava" +"Last on top" = "Poslední nahoře" +"Layer blur" = "Rozostření vrstvy" +"Layer name" = "Název vrstvy" +"Layers" = "Vrstvy" +"Layers in an instance take their position from the main component" = "Vrstvy v instanci přebírají polohu z hlavní komponenty" +"Layout" = "Rozvržení" +"Layout grid" = "Mřížka rozvržení" +"Layout grid settings" = "Nastavení mřížky rozvržení" +"Layout grids" = "Mřížky rozvržení" +"Leave session" = "Opustit relaci" +"Leaves" = "Listy" +"Left" = "Vlevo" +"Left & right" = "Vlevo a vpravo" +"Left side" = "Levá strana" +"Left side width" = "Šířka levé strany" +"Letter case" = "Velikost písmen" +"Letter spacing" = "Prostrkání znaků" +"Libraries" = "Knihovny" +"Libraries in this file" = "Knihovny v tomto souboru" +"Library updates available" = "Jsou dostupné aktualizace knihoven" +"Library: click to collapse" = "Knihovna: kliknutím sbalíte" +"Library: click to expand" = "Knihovna: kliknutím rozbalíte" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Knihovna: její proměnné jsou zde jen pro čtení; upravte je v souboru knihovny a publikujte ho" +"Light" = "Světlý" +"Lighten" = "Zesvětlit" +"Light|glass" = "Světlo" +"Line" = "Čára" +"Line arrow" = "Šipka z čar" +"Line delta" = "Změna počtu řádků" +"Line height" = "Výška řádku" +"Linear" = "Lineární" +"Lines" = "Čáry" +"Lines +/−" = "Řádky +/−" +"Lines added" = "Přidané řádky" +"Lines deleted" = "Smazané řádky" +"Link" = "Odkaz" +"Link copied" = "Odkaz zkopírován" +"Link docs, code or tickets for developers." = "Připojte odkazy na dokumentaci, kód nebo úkoly pro vývojáře." +"Link frames on the Prototype tab, then present the flow" = "Propojte rámce na kartě Prototyp a pak spusťte prezentaci toku" +"Link this component to its code component: instances then show your real snippet with their values." = "Propojte tuto komponentu s její komponentou v kódu: instance pak zobrazí váš skutečný úryvek kódu se svými hodnotami." +"List spacing" = "Rozestupy seznamu" +"List style" = "Styl seznamu" +"Live session" = "Živá relace" +"Live · {people}" = "Živě · {people}" +"Local components" = "Místní komponenty" +"Local network" = "Místní síť" +"Local styles" = "Místní styly" +"Local variables" = "Místní proměnné" +"Lock/Unlock" = "Zamknout/Odemknout" +"Locked" = "Zamknuto" +"Lowercase" = "Malá písmena" +"Luminance" = "Jas" +"Luminosity" = "Světlost" +"Main" = "Hlavní soubor" +"Main component deleted" = "Hlavní komponenta smazána" +"Main menu" = "Hlavní nabídka" +"Make a component" = "Vytvořit komponentu" +"Make slot" = "Vytvořit slot" +"Mar" = "března" +"Margin" = "Vnější okraj" +"Mark all as read" = "Označit vše jako přečtené" +"Mark as resolved" = "Označit jako vyřešené" +"Mark as unread" = "Označit jako nepřečtené" +"Mark as unresolved" = "Označit jako nevyřešené" +"Mask" = "Maska" +"Mass · Stiffness · Damping" = "Hmotnost · Tuhost · Tlumení" +"Match (empty: the whole name)" = "Hledaný text (prázdné: celý název)" +"Max H" = "Max. V" +"Max W" = "Max. Š" +"Max lines" = "Max. řádků" +"Maximize" = "Maximalizovat" +"May" = "května" +"Measure to the selection" = "Měřit k výběru" +"Merge into main" = "Sloučit do hlavního souboru" +"Merge regions" = "Sloučit oblasti" +"Merge the branch into the main file (one undo step) and archive it" = "Sloučit větev do hlavního souboru (jeden krok zpět) a archivovat ji" +"Merged" = "Sloučeno" +"Merged PRs" = "Sloučené PR" +"Min H" = "Min. V" +"Min W" = "Min. Š" +"Minimize" = "Minimalizovat" +"Mirror angle" = "Zrcadlit úhel" +"Mirror angle and length" = "Zrcadlit úhel a délku" +"Mirroring" = "Zrcadlení" +"Missing" = "Chybí" +"Missing layer" = "Chybějící vrstva" +"Missing style" = "Chybějící styl" +"Missing variable" = "Chybějící proměnná" +"Miter" = "Ostrý" +"Miter angle" = "Úhel ostrého spoje" +"Mixed" = "Smíšené" +"Mixed fonts" = "Smíšená písma" +"Mixed interactions: click + to replace them" = "Smíšené interakce: kliknutím na + je nahradíte" +"Mobile app" = "Mobilní aplikace" +"Mode" = "Režim" +"Model" = "Model" +"Models" = "Modely" +"Mono" = "Jednobarevné" +"More" = "Další" +"More instance actions" = "Další akce instance" +"Mouse down" = "Stisk myši" +"Mouse enter" = "Vstup ukazatele" +"Mouse leave" = "Odchod ukazatele" +"Mouse up" = "Uvolnění myši" +"Move along the toolbar or the layer rows" = "Pohyb po panelu nástrojů nebo řádcích vrstev" +"Move" = "Přesunout" +"Move column left" = "Přesunout sloupec vlevo" +"Move column right" = "Přesunout sloupec vpravo" +"Move down" = "Posunout dolů" +"Move in" = "Přesunout dovnitř" +"Move out" = "Přesunout ven" +"Move tools" = "Nástroje přesunu" +"Move up" = "Posunout nahoru" +"Moved to {name}" = "Přesunuto do {name}" +"Multi" = "Vícebarevné" +"Multiply" = "Násobit" +"Muted" = "Tlumené" +"Name" = "Název" +"Name (A–Z)" = "Název (A–Z)" +"Named instance" = "Pojmenovaná instance" +"Navigate to" = "Přejít na" +"Nested instance" = "Vnořená instance" +"New design file" = "Nový soubor návrhu" +"Newest first" = "Od nejnovějších" +"Next or previous control in the focused region" = "Další nebo předchozí ovládací prvek v zaměřené oblasti" +"Next ready for dev" = "Další připravené pro vývoj" +"Next size option" = "Další možnost velikosti" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Žádné zdroje: chybí nastavení exportu, ikony i obrázky. Přidejte nastavení exportu na kartě Návrh." +"No boolean variables fit here." = "Žádné vhodné logické proměnné." +"No boolean variables yet. Create them in Local variables." = "Zatím žádné logické proměnné. Vytvořte je v Místních proměnných." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Žádné větve. Větev je kopie souboru pro vyzkoušení změn, které pak zkontrolujete a sloučíte." +"No category" = "Bez kategorie" +"No changes" = "Žádné změny" +"No colour variables fit here." = "Žádné vhodné barevné proměnné." +"No colour variables yet. Create them in Local variables." = "Zatím žádné barevné proměnné. Vytvořte je v Místních proměnných." +"No comments yet" = "Zatím žádné komentáře" +"No components yet. Select layers and press {keys}." = "Zatím žádné komponenty. Vyberte vrstvy a stiskněte {keys}." +"No conflicts." = "Žádné konflikty." +"No contributor data was built into this copy." = "Toto sestavení neobsahuje údaje o přispěvatelích." +"No fonts match" = "Žádná odpovídající písma" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Kód pro připojení není dostupný (server kódy nepodporuje nebo tuto adresu nelze do kódu vložit): sdílejte odkaz." +"No layer changes to show." = "Žádné změny vrstev k zobrazení." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Zatím žádné knihovny. Přidejte soubor .figcraft publikovaný jako knihovna nebo vložte odkaz na týmovou knihovnu." +"No list" = "Bez seznamu" +"No matching comments" = "Žádné odpovídající komentáře" +"No matching components" = "Žádné odpovídající komponenty" +"No matching layers" = "Žádné odpovídající vrstvy" +"No matching styles" = "Žádné odpovídající styly" +"No matching variables" = "Žádné odpovídající proměnné" +"No mirroring" = "Bez zrcadlení" +"No model credits were built into this copy." = "Toto sestavení neobsahuje údaje o podílu modelů." +"No number variables fit here." = "Žádné vhodné číselné proměnné." +"No number variables yet. Create them in Local variables." = "Zatím žádné číselné proměnné. Vytvořte je v Místních proměnných." +"No paints" = "Žádné výplně" +"No plugins on {server}." = "Na serveru {server} nejsou žádné pluginy." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Zatím žádné pluginy. Importujte vývojový plugin z jeho souboru figcraft-plugin.json." +"No preferred components" = "Žádné preferované komponenty" +"No preset" = "Bez předvolby" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Zatím žádné uložené verze. Uložte verzi jako bod práce, ke kterému se můžete vrátit." +"No scrolling" = "Bez posouvání" +"No shortcuts match" = "Žádné odpovídající zkratky" +"No status" = "Bez stavu" +"No string variables fit here." = "Žádné vhodné textové proměnné." +"No string variables yet. Create them in Local variables." = "Zatím žádné textové proměnné. Vytvořte je v Místních proměnných." +"No styles yet." = "Zatím žádné styly." +"No styles yet. Click + to create one from the selection." = "Zatím žádné styly. Kliknutím na + vytvoříte styl z výběru." +"No templates match your search" = "Žádné šablony neodpovídají hledání" +"No text styles yet. Click + to create one from the selected text." = "Zatím žádné textové styly. Kliknutím na + vytvoříte styl z vybraného textu." +"No variables fit here." = "Žádné vhodné proměnné." +"No variables yet. Create them in Local variables." = "Zatím žádné proměnné. Vytvořte je v Místních proměnných." +"Nobody else is here yet" = "Zatím tu není nikdo další" +"Noise" = "Šum" +"None" = "Žádné" +"None." = "Žádné." +"Normal" = "Normální" +"Not bound" = "Nenavázáno" +"Not in a live session" = "Mimo živou relaci" +"Not in this font" = "Není v tomto písmu" +"Not properties, kept as written: {names}" = "Nejde o vlastnosti, ponecháno beze změny: {names}" +"Not read yet" = "Dosud nepřečteno" +"Not shared" = "Nesdíleno" +"Note {n}" = "Poznámka {n}" +"Nothing changed since this was marked ready for dev." = "Od označení jako připravené pro vývoj se nic nezměnilo." +"Nothing here yet. Set a frame's status to list it." = "Zatím nic. Pro zobrazení rámce v seznamu nastavte jeho stav." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Zatím nic online. Nahrajte soubor online pomocí Sdílet a zobrazí se zde." +"Nothing to export" = "Není co exportovat" +"Nov" = "listopadu" +"Now" = "Nyní" +"Nudge (Shift: big nudge)" = "Posunout (Shift: větší posun)" +"Nudge amount" = "Velikost posunu" +"Number" = "Číslo" +"Number ↑" = "Číslo ↑" +"Number ↓" = "Číslo ↓" +"Numbered list" = "Číslovaný seznam" +"Object" = "Objekt" +"Oct" = "října" +"Off" = "Vypnuto" +"Offered first when filling the slot (any content is allowed)" = "Nabídne se jako první při vyplňování slotu (povolen je libovolný obsah)" +"Offered first when swapping the instance (any component is allowed)" = "Nabídne se jako první při výměně instance (povolena je libovolná komponenta)" +"Offset" = "Posun" +"Oldest first" = "Od nejstarších" +"On click" = "Při kliknutí" +"On drag" = "Při tažení" +"On this page" = "Na této stránce" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Jedno oznámení v pěti běžných velikostech: čtverec, na výšku, příběh, odkaz a banner" +"Online" = "Online" +"Online files" = "Online soubory" +"Online sharing is off: files are shared on the local network only." = "Online sdílení je vypnuté: soubory se sdílejí pouze v místní síti." +"Only the first changed layers are listed." = "Zobrazeny jsou pouze první změněné vrstvy." +"Only the first {n} changed layers are listed." = "Zobrazeno je pouze prvních {n} změněných vrstev." +"Only your threads" = "Pouze vaše vlákna" +"Opacity" = "Krytí" +"Opacity & blend" = "Krytí a prolnutí" +"Opacity 10%…90%, 0: 100%" = "Krytí 10 %…90 %, 0: 100 %" +"Open" = "Otevřít" +"Open .figcraft, .svg or images" = "Otevřít .figcraft, .svg nebo obrázky" +"Open branch" = "Otevřít větev" +"Open file" = "Otevřít soubor" +"Open files" = "Otevřít soubory" +"Open in focus view" = "Otevřít v zaměřeném pohledu" +"Open in new window" = "Otevřít v novém okně" +"Open invite links in FigCraft" = "Otevírat odkazy s pozvánkou v aplikaci FigCraft" +"Open link" = "Otevřít odkaz" +"Open link {url}" = "Otevřít odkaz {url}" +"Open overlay" = "Otevřít překryv" +"Open prototype preview" = "Otevřít náhled prototypu" +"Open variables ({count})" = "Otevřít proměnné ({count})" +"Opened “{name}” in a new tab" = "Soubor „{name}“ otevřen v nové kartě" +"Open|comments" = "Otevřené" +"Options" = "Možnosti" +"Orange" = "Oranžová" +"Order" = "Pořadí" +"Other apps may not open every .fig file exported here." = "Jiné aplikace nemusí otevřít všechny zde exportované soubory .fig." +"Others lose access and keep their copy; the file stays on the server for you" = "Ostatní ztratí přístup a ponechají si svou kopii; soubor pro vás zůstane na serveru" +"Others type this under Share ▸ Join with a code" = "Ostatní tento kód zadají v nabídce Sdílet ▸ Připojit se pomocí kódu" +"Outline stroke" = "Převést tah na obrysy" +"Outline text" = "Převést text na obrysy" +"Outlines" = "Obrysy" +"Outside" = "Vně" +"Overflow" = "Přetečení" +"Overlay" = "Překryv" +"Overlay position" = "Poloha překryvu" +"Overridden here (the collection it extends has another value)" = "Zde přepsáno (rozšiřovaná kolekce má jinou hodnotu)" +"Overrides" = "Přepsání" +"Owner token" = "Token vlastníka" +"Owner token copied" = "Token vlastníka zkopírován" +"PDF print options" = "Možnosti tisku PDF" +"PDF print settings" = "Nastavení tisku PDF" +"Padding" = "Vnitřní odsazení" +"Page" = "Stránka" +"Pages" = "Stránky" +"Pages: {pages}" = "Stránky: {pages}" +"Paint bucket" = "Plechovka barvy" +"Pan the canvas" = "Posunout plátno" +"Pan with nothing selected (Shift: farther)" = "Posun bez výběru (Shift: dále)" +"Panels" = "Panely" +"Paper" = "Papír" +"Paragraph indent" = "Odsazení odstavce" +"Paragraph indent applies to left-aligned text" = "Odsazení odstavce se vztahuje na text zarovnaný vlevo" +"Paragraph spacing" = "Mezery mezi odstavci" +"Pass through" = "Průchozí" +"Paste a link (https://…)" = "Vložte odkaz (https://…)" +"Paste a team library link (a view link)" = "Vložte odkaz na týmovou knihovnu (odkaz pro prohlížení)" +"Paste here" = "Vložit sem" +"Paste to replace" = "Vložit a nahradit" +"Pattern" = "Vzor" +"Pattern alignment" = "Zarovnání vzoru" +"Pattern scale" = "Měřítko vzoru" +"Pattern source" = "Zdroj vzoru" +"Pen" = "Pero" +"Pencil" = "Tužka" +"People in this file" = "Lidé v tomto souboru" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Lidé se připojí odkudkoli pomocí kódu. Po dobu sdílení je soubor uložen na serveru {server}." +"People on your network join with a code. Nothing goes through a server." = "Lidé ve vaší síti se připojí pomocí kódu. Nic neprochází přes server." +"Phone" = "Telefon" +"Pick color" = "Vybrat barvu" +"Pick color (I)" = "Vybrat barvu (I)" +"Pick one font family first" = "Nejprve vyberte jednu rodinu písma" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Vyberte nástroj Rámec a pak táhněte na plátně nebo zvolte velikost zařízení" +"Pin a property" = "Připnout vlastnost" +"Pinch" = "Sevření" +"Pixel grid" = "Pixelová mřížka" +"Pixel preview 1x" = "Pixelový náhled 1x" +"Pixel preview 2x" = "Pixelový náhled 2x" +"Place a shape or frame (its tool chosen)" = "Umístit tvar nebo rámec (s vybraným nástrojem)" +"Place automatically" = "Umístit automaticky" +"Place image" = "Umístit obrázek" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Pluginy běží v izolovaném prostředí aplikace FigCraft bez přístupu k souborům, síti nebo hodinám. Instalujte pouze pluginy, kterým důvěřujete." +"Plugins" = "Pluginy" +"Plus darker" = "Sčítání se ztmavením" +"Plus lighter" = "Sčítání se zesvětlením" +"Point" = "Bod" +"Points" = "Body" +"Polygon" = "Mnohoúhelník" +"Polygons" = "Mnohoúhelníky" +"Port" = "Port" +"Position" = "Poloha" +"Post" = "Odeslat" +"Preferences" = "Předvolby" +"Preferred" = "Preferované" +"Present" = "Prezentovat" +"Present or preview" = "Prezentovat nebo zobrazit náhled" +"Present this flow" = "Prezentovat tento tok" +"Presentation" = "Prezentace" +"Presentation slides" = "Snímky prezentace" +"Presenting: comments" = "Prezentace: komentáře" +"Presenting: cycle the size options" = "Prezentace: přepnout možnosti velikosti" +"Presenting: full screen" = "Prezentace: celá obrazovka" +"Presenting: next frame" = "Prezentace: další rámec" +"Presenting: previous frame" = "Prezentace: předchozí rámec" +"Presenting: restart" = "Prezentace: znovu spustit" +"Preserve scroll position" = "Zachovat pozici posouvání" +"Presets" = "Předvolby" +"Press a key…" = "Stiskněte klávesu…" +"Playground" = "Zkušební prostředí" +"Preview" = "Náhled" +"Preview this flow" = "Náhled tohoto toku" +"Preview this frame" = "Náhled tohoto rámce" +"Previous ready for dev" = "Předchozí připravené pro vývoj" +"Progressive" = "Postupné" +"Properties" = "Vlastnosti" +"Prototype" = "Prototyp" +"Prototype preview" = "Náhled prototypu" +"Prototype settings" = "Nastavení prototypu" +"Publish" = "Publikovat" +"Publish changes" = "Publikovat změny" +"Publish to another server…" = "Publikovat na jiný server…" +"Publish to server" = "Publikovat na server" +"Published version {v}" = "Publikovaná verze {v}" +"Published version {v} to the server" = "Verze {v} publikována na server" +"Published version {v}: save the file so other files can use it" = "Publikována verze {v}: uložte soubor, aby ji mohly používat ostatní soubory" +"Published · {people}" = "Publikováno · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publikování na server vyžaduje desktopovou aplikaci; v prohlížeči se můžete připojit k souborům na serveru" +"Publishing with the server's key." = "Publikování s klíčem serveru." +"Publishing…" = "Publikování…" +"Push" = "Vytlačení" +"Push changes to main component" = "Přenést změny do hlavní komponenty" +"Put text on path" = "Umístit text na cestu" +"R" = "R" +"Radial" = "Kruhový" +"Radius" = "Poloměr" +"Ratio" = "Poměr" +"Reading the library…" = "Načítání knihovny…" +"Ready for dev" = "Připraveno pro vývoj" +"Real name" = "Skutečné jméno" +"Recent" = "Nedávné" +"Recent files" = "Nedávné soubory" +"Recents" = "Nedávné" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Po neočekávaném ukončení aplikace obnoven {n} neuložený soubor: uložte ho pro zachování", other = "Po neočekávaném ukončení aplikace obnoveny neuložené soubory (počet: {n}): uložte je pro zachování" } +"Rectangle" = "Obdélník" +"Rectangles" = "Obdélníky" +"Red" = "Červená" +"Reduce motion" = "Omezit pohyb" +"Refraction" = "Lom světla" +"Regular expression" = "Regulární výraz" +"Remove" = "Odebrat" +"Remove all" = "Odebrat vše" +"Remove annotation" = "Odebrat anotaci" +"Remove auto layout" = "Odebrat automatické rozvržení" +"Remove dev resource" = "Odebrat zdroj pro vývojáře" +"Remove export setting" = "Odebrat nastavení exportu" +"Remove from recents" = "Odebrat z nedávných" +"Remove from server" = "Odebrat ze serveru" +"Remove from server…" = "Odebrat ze serveru…" +"Remove guide" = "Odebrat vodítko" +"Remove interaction" = "Odebrat interakci" +"Remove library from this file" = "Odebrat knihovnu z tohoto souboru" +"Remove min and max" = "Odebrat minimum a maximum" +"Remove min and max height" = "Odebrat minimální a maximální výšku" +"Remove min and max width" = "Odebrat minimální a maximální šířku" +"Remove regions" = "Odebrat oblasti" +"Remove slot" = "Odebrat slot" +"Remove starting point" = "Odebrat počáteční bod" +"Remove stop" = "Odebrat zarážku" +"Remove text from path" = "Odebrat text z cesty" +"Remove the else and its actions" = "Odebrat větev Jinak a její akce" +"Remove this condition and its actions" = "Odebrat tuto podmínku a její akce" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Odebrat tento soubor ze serveru? Již odtud nepůjde sdílet ani otevřít. Vaše kopie zde zůstane otevřená." +"Remove {name} from {server}?" = "Odebrat {name} ze serveru {server}?" +"Removed" = "Odebráno" +"Removed from the server" = "Odebráno ze serveru" +"Removed since it was marked" = "Odebráno od označení" +"Rename" = "Přejmenovat" +"Rename duplicated layers" = "Přejmenovávat duplikované vrstvy" +"Rename folder" = "Přejmenovat složku" +"Rename group" = "Přejmenovat skupinu" +"Rename layers" = "Přejmenovat vrstvy" +"Rename mode" = "Přejmenovat režim" +"Rename on the server" = "Přejmenovat na serveru" +"Rename on the server…" = "Přejmenovat na serveru…" +"Rename page" = "Přejmenovat stránku" +"Rename to" = "Přejmenovat na" +"Replace all" = "Nahradit vše" +"Replace mixed export settings" = "Nahradit smíšená nastavení exportu" +"Replace with" = "Nahradit za" +"Replace with a new interaction" = "Nahradit novou interakcí" +"Reply" = "Odpovědět" +"Report" = "Nahlásit" +"Report this file…" = "Nahlásit tento soubor…" +"Reset" = "Obnovit výchozí" +"Reset adjustments" = "Obnovit úpravy" +"Reset all changes" = "Vrátit všechny změny" +"Reset all overrides on this layer" = "Zrušit všechna přepsání této vrstvy" +"Reset overrides" = "Zrušit přepsání" +"Reset slot" = "Obnovit slot" +"Reset this override" = "Zrušit toto přepsání" +"Reset to style" = "Obnovit podle stylu" +"Reset to the inherited value" = "Obnovit zděděnou hodnotu" +"Resize by the nudge amount" = "Změnit velikost o velikost posunu" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Přizpůsobit velikost rámců obrazovce zařízení (nebo oknu bez zařízení): omezení a automatické rozvržení se přizpůsobí" +"Resize to fit" = "Přizpůsobit velikost obsahu" +"Resolve" = "Vyřešit" +"Resolve change" = "Vyřešit změnu" +"Resolved" = "Vyřešené" +"Resources" = "Zdroje" +"Responsive" = "Responzivní" +"Restart" = "Znovu spustit" +"Restore" = "Obnovit" +"Restore main component" = "Obnovit hlavní komponentu" +"Restore this version" = "Obnovit tuto verzi" +"Restored “{name}”" = "Obnoveno: „{name}“" +"Reverse the order" = "Obrátit pořadí" +"Reversed triangle" = "Obrácený trojúhelník" +"Review and merge" = "Zkontrolovat a sloučit" +"Ribbon" = "Stuha" +"Right" = "Vpravo" +"Right side" = "Pravá strana" +"Right side width" = "Šířka pravé strany" +"Rotate 90°" = "Otočit o 90°" +"Rotate 90° right" = "Otočit o 90° doprava" +"Rotate to landscape" = "Otočit na šířku" +"Rotate to portrait" = "Otočit na výšku" +"Rotation" = "Natočení" +"Rotation jitter" = "Náhodná odchylka natočení" +"Round" = "Kulatý" +"Rounded" = "Zaoblený" +"Row" = "Řádek" +"Row gap" = "Mezera mezi řádky" +"Row sizes" = "Velikosti řádků" +"Row span" = "Rozpětí přes řádky" +"Rows" = "Řádky" +"Rows {count}" = "Řádky: {count}" +"Rulers" = "Pravítka" +"Run" = "Spustit" +"Run {label}" = "Spustit {label}" +"Search templates" = "Hledat šablony" +"See all templates" = "Zobrazit všechny šablony" +"Select layers and they space themselves as their content changes" = "Vyberte vrstvy a jejich rozestupy se přizpůsobí změnám obsahu" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Stejné jako vrstva" +"Saturation" = "Sytost" +"Save" = "Uložit" +"Save as" = "Uložit jako" +"Save as preset…" = "Uložit jako předvolbu…" +"Save version {key}" = "Uložit verzi {key}" +"Saved" = "Uloženo" +"Saved {name}" = "Uloženo: {name}" +"Say something" = "Napište něco" +"Scale" = "Měřítko" +"Scatter" = "Rozptyl" +"Scoping: where pickers offer it" = "Rozsah použití: kde se nabízí ve výběru" +"Screen" = "Závoj" +"Scroll behavior" = "Chování při posouvání" +"Scroll to" = "Posunout na" +"Scroll to layer" = "Posunout na vrstvu" +"Scroll with parent" = "Posouvat s nadřazeným prvkem" +"Search plugins" = "Hledat pluginy" +"Search" = "Hledat" +"Search actions, layers and tools" = "Hledat akce, vrstvy a nástroje" +"Search assets" = "Hledat zdroje" +"Search comments" = "Hledat komentáře" +"Search fonts" = "Hledat písma" +"Search layers" = "Hledat vrstvy" +"Search shortcuts" = "Hledat zkratky" +"Section" = "Sekce" +"Section color" = "Barva sekce" +"Sections" = "Sekce" +"Select a frame and add a starting point to present it as a flow." = "Vyberte rámec a přidejte počáteční bod pro prezentaci jako tok." +"Select a frame to preview it" = "Vyberte rámec pro náhled" +"Select a layer to see its properties and code" = "Vyberte vrstvu a zobrazte její vlastnosti a kód" +"Select all" = "Vybrat vše" +"Select all with same" = "Vybrat vše se stejným" +"Select layer" = "Vybrat vrstvu" +"Select layers to apply the style to" = "Vyberte vrstvy, na které chcete použít styl" +"Select overlapping shapes" = "Vyberte překrývající se tvary" +"Select points to edit their position, radius and mirroring" = "Vyberte body a upravte jejich polohu, poloměr a zrcadlení" +"Select slot" = "Vybrat slot" +"Selection" = "Výběr" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Rámeček výběru, {w} × {h} na pozici {x}, {y}: šipky posouvají, Shift+šipky nebo {cmd}+šipky mění velikost, Enter vybere, Escape zruší" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Rámeček výběru: šipky posouvají, Shift+šipky nebo {cmd}+šipky mění velikost, Enter vybere, Escape zruší" +"Send backward" = "Přesunout o úroveň níž" +"Send report" = "Odeslat hlášení" +"Send to back" = "Přesunout do pozadí" +"Sep" = "září" +"Server address (ws://host:47900/)" = "Adresa serveru (ws://host:47900/)" +"Server address" = "Adresa serveru" +"Server for online sharing" = "Server pro online sdílení" +"Set as default" = "Nastavit jako výchozí" +"Set variable" = "Nastavit proměnnou" +"Set variable mode" = "Nastavit režim proměnných" +"Set variable {name}" = "Nastavit proměnnou {name}" +"Set {name} to {value}" = "Nastavit {name} na {value}" +"Shadows" = "Stíny" +"Shape" = "Tvar" +"Shape builder" = "Tvorba tvarů" +"Shape tools" = "Nástroje tvarů" +"Share" = "Sdílet" +"Share this file" = "Sdílet tento soubor" +"Share with a code" = "Sdílet pomocí kódu" +"Share: live session, export" = "Sdílet: živá relace, export" +"Shared" = "Sdíleno" +"Shared: copy its code from the file's menu" = "Sdíleno: zkopírujte kód z nabídky souboru" +"Sharing a file needs the desktop app; here you can join one." = "Sdílení souboru vyžaduje desktopovou aplikaci; zde se můžete k souboru připojit." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Sdílení v místní síti (online sdílení je vypnuté: v předvolbách není nastaven server)." +"Sharing on your network · {people}" = "Sdílení ve vaší síti · {people}" +"Sharing online · {people}" = "Sdílení online · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Sdílení zastaveno: soubor zůstává na serveru, přístupný pouze vám" +"Show" = "Zobrazit" +"Show all templates" = "Zobrazit všechny šablony" +"Show as grid" = "Zobrazit jako mřížku" +"Show as list" = "Zobrazit jako seznam" +"Show autosaves" = "Zobrazit automaticky uložené verze" +"Show behind transparent areas" = "Zobrazit za průhlednými oblastmi" +"Show comments (Shift+C)" = "Zobrazit komentáře (Shift+C)" +"Show device frame" = "Zobrazit rámeček zařízení" +"Show dimensions on objects" = "Zobrazit rozměry objektů" +"Show hotspot hints" = "Zobrazit nápovědu interaktivních oblastí" +"Show in Explorer" = "Zobrazit v Průzkumníku" +"Show in Finder" = "Zobrazit v aplikaci Finder" +"Show in folder" = "Zobrazit ve složce" +"Show only this on the canvas" = "Zobrazit na plátně pouze toto" +"Show open" = "Zobrazit otevřené" +"Show section contents" = "Zobrazit obsah sekce" +"Show this layer" = "Zobrazit tuto vrstvu" +"Show usage" = "Zobrazit použití" +"Show/Hide" = "Zobrazit/Skrýt" +"Show/Hide UI" = "Zobrazit/Skrýt rozhraní" +"Side by side" = "Vedle sebe" +"Sidebar" = "Postranní panel" +"Simplify stroke" = "Zjednodušit tah" +"Size" = "Velikost" +"Size jitter" = "Náhodná odchylka velikosti" +"Skip ink" = "Vynechat tahy písmen" +"Slashed zero" = "Přeškrtnutá nula" +"Slice" = "Výřez" +"Slices" = "Výřezy" +"Slide in" = "Zasunutí" +"Slide out" = "Vysunutí" +"Slot" = "Slot" +"Slot content" = "Obsah slotu" +"Small capitals" = "Kapitálky" +"Small caps" = "Kapitálky" +"Smart animate" = "Chytrá animace" +"Snap to pixel grid" = "Přichytávat k pixelové mřížce" +"Social and slides" = "Sociální sítě a snímky" +"Social media" = "Sociální sítě" +"Social post sizes" = "Velikosti příspěvků na sociální sítě" +"Soft light" = "Měkké světlo" +"Solid" = "Plná barva" +"Some variants have the same property values: give each one a unique combination" = "Některé varianty mají stejné hodnoty vlastností: dejte každé jedinečnou kombinaci" +"Sort" = "Řadit" +"Sort by" = "Řadit podle" +"Sort by date" = "Řadit podle data" +"Sort by page" = "Řadit podle stránky" +"Sort by {column}" = "Řadit podle {column}" +"Space around" = "Mezery kolem" +"Space between" = "Mezery mezi" +"Space drag" = "Mezerník a tažení" +"Space evenly" = "Stejné mezery" +"Space+drag" = "Mezerník+tažení" +"Spacing" = "Rozestupy" +"Spam, stolen work, abuse…" = "Spam, odcizená práce, zneužití…" +"Splay" = "Rozevření" +"Spotlight me" = "Sdílet můj pohled" +"Spread" = "Rozšíření" +"Square" = "Čtverec" +"Standard" = "Standardní" +"Standard ligatures" = "Standardní ligatury" +"Star" = "Hvězda" +"Stars" = "Hvězdy" +"Start" = "Začátek" +"Start ascending from / stop descending at" = "Začít vzestupně od / skončit sestupně na" +"Start from a blank canvas" = "Začít s prázdným plátnem" +"Start from a template" = "Začít ze šablony" +"Start on path" = "Začátek na cestě" +"Start point" = "Počáteční bod" +"Start sharing" = "Zahájit sdílení" +"Start sharing with the Share button, then pass on the code it gives" = "Zahajte sdílení tlačítkem Sdílet a předejte získaný kód" +"Starting to share…" = "Zahajování sdílení…" +"Status" = "Stav" +"Sticky" = "Přichycené" +"Stop" = "Zastavit" +"Stop following {name}" = "Přestat sledovat: {name}" +"Stop session" = "Ukončit relaci" +"Stop sharing" = "Zastavit sdílení" +"Stop spotlighting" = "Ukončit sdílení pohledu" +"Stop {n} {hex}" = "Zarážka {n} {hex}" +"Stretch" = "Roztáhnout" +"Strikethrough" = "Přeškrtnutí" +"String" = "Textový řetězec" +"Stroke" = "Tah" +"Stroke style" = "Styl tahu" +"Stroke weight" = "Tloušťka tahu" +"Strokes" = "Tahy" +"Style" = "Styl" +"Styles" = "Styly" +"Stylistic set 1" = "Stylistická sada 1" +"Stylistic set 2" = "Stylistická sada 2" +"Stylistic set 3" = "Stylistická sada 3" +"Stylistic set 4" = "Stylistická sada 4" +"Stylistic set 5" = "Stylistická sada 5" +"Subtract" = "Odečíst" +"Subtracts" = "Odečtení" +"Suffix" = "Přípona" +"Suspend snapping while dragging" = "Dočasně vypnout přichytávání při tažení" +"Swap instance" = "Vyměnit instanci" +"Swap library: use another library's assets of the same names" = "Vyměnit knihovnu: použít stejně pojmenované zdroje jiné knihovny" +"Swap orientation" = "Změnit orientaci" +"Swap overlay" = "Vyměnit překryv" +"Swap to {name}" = "Vyměnit za {name}" +"Swap width and height" = "Prohodit šířku a výšku" +"Swapped instance" = "Instance vyměněna" +"Swapped {n} assets" = { one = "Vyměněn {n} zdroj", other = "Vyměněné zdroje: {n}" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Vyměněn {n} zdroj; bez shody: {missed}, nadále používají původní knihovnu", other = "Vyměněné zdroje: {n}; bez shody: {missed}, nadále používají původní knihovnu" } +"Sweep" = "Rozsah" +"System" = "Systém" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Tabulka" +"Tablet" = "Tablet" +"Tabular numbers" = "Tabulkové číslice" +"Take the values from the first selected layer" = "Převzít hodnoty z první vybrané vrstvy" +"Taper" = "Zúžení" +"Taper both ends" = "Zúžení obou konců" +"Tell the server's operator what is wrong with it" = "Sdělte provozovateli serveru, co je se souborem v nepořádku" +"Temperature" = "Teplota" +"Template" = "Šablona" +"Templates" = "Šablony" +"Terms" = "Podmínky" +"Text" = "Text" +"Text align" = "Zarovnání textu" +"Text content" = "Textový obsah" +"Text style" = "Textový styl" +"Text styles" = "Textové styly" +"Texts" = "Texty" +"Texture" = "Textura" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Plátno zobrazuje barvy v sRGB; exportované soubory PNG, JPG a WebP mají profil Display P3." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Soubor se smaže ze serveru: nikdo ho odtud již neotevře ani se k němu nepřipojí a tuto akci nelze vrátit zpět. Otevřené kopie zůstanou těm, kteří je mají." +"The file now holds another library" = "Soubor nyní obsahuje jinou knihovnu" +"The file now holds another library (\"{name}\")" = "Soubor nyní obsahuje jinou knihovnu („{name}“)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Soubory sdílené nebo publikované online z tohoto počítače. Zůstanou na svém serveru, dokud je neodeberete." +"The link carries the session's secret: share it only with people you invite." = "Odkaz obsahuje tajný údaj relace: sdílejte ho pouze s pozvanými lidmi." +"The name in your online files and on the server. The document inside keeps its own name." = "Název v online souborech a na serveru. Dokument uvnitř si ponechá vlastní název." +"The selection uses several text styles: click to apply one" = "Výběr používá více textových stylů: kliknutím použijete jeden" +"The server deletes this file if nobody opens it before {date}." = "Server tento soubor smaže, pokud ho nikdo neotevře před {date}." +"The whole file" = "Celý soubor" +"Theme" = "Motiv" +"There's no destination for that action" = "Tato akce nemá cíl" +"Thickness" = "Tloušťka" +"This branch can't be reviewed: {e}" = "Tuto větev nelze zkontrolovat: {e}" +"This branch was merged or archived." = "Tato větev byla sloučena nebo archivována." +"This computer only" = "Pouze tento počítač" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Tento počítač si pamatuje token vlastníka, takže soubor můžete spravovat ze seznamu Online na domovské obrazovce. Zkopírujte ho pro správu souboru odjinud: je to heslo k tomuto souboru a znovu se nezobrazí." +"This condition is edited as an expression" = "Tato podmínka se upravuje jako výraz" +"This file" = "Tento soubor" +"This file has unsaved changes" = "Tento soubor obsahuje neuložené změny" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Tento soubor je na serveru {server}, přístupný pouze vám. Sdílením umožníte ostatním připojení pomocí nového kódu." +"This frame is new on the branch." = "Tento rámec je ve větvi nový." +"This frame isn't on the branch." = "Tento rámec není ve větvi." +"This server is open to the public: no key needed." = "Tento server je veřejný: klíč není potřeba." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Tento server vyžaduje klíč: přidejte do adresy ?key=… (příkaz `figcraft-server key` ho vypíše na počítači serveru)." +"This server takes files only with its key: see Advanced." = "Tento server přijímá soubory pouze s klíčem: viz Pokročilé." +"Tidy up" = "Uspořádat" +"Tile" = "Dlaždice" +"Tile type" = "Typ dlaždic" +"Tint" = "Tónování" +"Title" = "Název" +"Title case" = "Velká počáteční písmena slov" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Chcete-li sdílet tuto knihovnu s týmem, publikujte soubor na server (Sdílet ▸ Publikovat na server…) a pak tam publikujte knihovnu." +"Toggle" = "Přepnout" +"Toggle light/dark theme" = "Přepnout světlý/tmavý motiv" +"Toggle visibility" = "Přepnout viditelnost" +"Tools" = "Nástroje" +"Top" = "Nahoře" +"Top & bottom" = "Nahoře a dole" +"Top left radius" = "Poloměr levého horního rohu" +"Top right radius" = "Poloměr pravého horního rohu" +"Transform" = "Transformace" +"Triangle arrow" = "Trojúhelníková šipka" +"Trigger" = "Spouštěč" +"True" = "Pravda" +"Truncate text" = "Zkrátit text" +"Truncate with an ellipsis" = "Zkrátit výpustkou" +"Try again" = "Zkusit znovu" +"Try another search or filter." = "Zkuste jiné hledání nebo filtr." +"Try another word, or look at every kind." = "Zkuste jiné slovo nebo zobrazte všechny druhy." +"Turn a layer into a component, reuse it, and change every copy at once" = "Převeďte vrstvu na komponentu, opakovaně ji používejte a měňte všechny kopie najednou" +"Two digits" = "Dvě číslice" +"Type" = "Typ" +"Type settings" = "Nastavení písma" +"Type the join code you were given, or paste an invite link." = "Zadejte obdržený kód pro připojení nebo vložte odkaz s pozvánkou." +"Typography" = "Typografie" +"Underline" = "Podtržení" +"Undo takes back only your own changes; what others changed since stays." = "Zpět vrací pouze vaše vlastní změny; pozdější změny ostatních zůstanou." +"Ungroup" = "Zrušit seskupení" +"Uniform" = "Rovnoměrný" +"Union" = "Sjednocení" +"Unions" = "Sjednocení" +"Unknown — treated as sRGB" = "Neznámý — zpracovává se jako sRGB" +"Unread" = "Nepřečtené" +"Unresolve" = "Znovu otevřít" +"Unsaved changes" = "Neuložené změny" +"Unsaved changes in {names}. Close the window and lose them?" = "Neuložené změny v {names}. Zavřít okno a zahodit je?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Neuložené změny: nejprve uložte ({save}) nebo zavřete kartu kliknutím s {alt} a zahoďte je" +"Unsaved changes: save first, or close again with {key} to discard" = "Neuložené změny: nejprve uložte nebo zavřete znovu s {key} a zahoďte je" +"Up" = "Nahoru" +"Update plugin" = "Aktualizovat plugin" +"Update" = "Aktualizovat" +"Update from main" = "Aktualizovat z hlavního souboru" +"Update from selection" = "Aktualizovat z výběru" +"Updated {components} and {styles}" = "Aktualizováno – {components}, {styles}" +"Updated {name}" = "Aktualizováno: {name}" +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Nahraje tento soubor na server FigCraft a udržuje s ním tuto kartu synchronizovanou. Získáte odkaz pro úpravy a odkaz pro prohlížení." +"Uppercase" = "Velká písmena" +"Use a fixed value" = "Použít pevnou hodnotu" +"Use an expression, e.g. count + 1" = "Použít výraz, např. count + 1" +"Use as mask" = "Použít jako masku" +"Use auto layout" = "Použít automatické rozvržení" +"Use number keys for opacity" = "Nastavovat krytí číselnými klávesami" +"Use the layer's fill" = "Použít výplň vrstvy" +"Used in {files} · {instances}" = "Použití – {files} · {instances}" +"Username" = "Uživatelské jméno" +"Value" = "Hodnota" +"Uses variables: each layer shows them in its own mode" = "Používá proměnné: každá vrstva je zobrazuje ve vlastním režimu" +"Variable" = "Proměnná" +"Variable collections" = "Kolekce proměnných" +"Variable modes" = "Režimy proměnných" +"Variable width" = "Proměnlivá šířka" +"Variables" = "Proměnné" +"Variables JSON…" = "Proměnné JSON…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Proměnné uchovávají opakovaně použitelné hodnoty (barvy, čísla, textové řetězce, logické hodnoty) s režimy jako Světlý a Tmavý." +"Variant" = "Varianta" +"Vector" = "Vektor" +"Vectors" = "Vektory" +"Version" = "Verze" +"Version history" = "Historie verzí" +"Version {version}" = "Verze {version}" +"Version {v} · {components} · {styles} · {variables}" = "Verze {v} · {components} · {styles} · {variables}" +"Vertical" = "Svisle" +"Vertical align" = "Svislé zarovnání" +"Vertical hex" = "Svislý šestiúhelník" +"Vertical padding" = "Svislé vnitřní odsazení" +"Vertical spacing" = "Svislé rozestupy" +"Vertical trim" = "Svislý ořez" +"View" = "Zobrazení" +"View link" = "Odkaz pro prohlížení" +"View link copied" = "Odkaz pro prohlížení zkopírován" +"View only" = "Pouze prohlížení" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Pouze prohlížení: vidíte každou změnu průběžně, ale nemůžete upravovat. Opuštěním relace si ponecháte kopii, kterou můžete upravovat." +"Viewing “{name}”" = "Prohlížení „{name}“" +"Violet" = "Fialová" +"Visibility" = "Viditelnost" +"Visible" = "Viditelné" +"Vivid" = "Živé" +"Warm" = "Teplé" +"Watch" = "Hodinky" +"Wavy" = "Vlnitý" +"Web" = "Web" +"Websites" = "Webové stránky" +"WebSocket URL" = "URL WebSocketu" +"WebSocket URL copied" = "URL WebSocketu zkopírována" +"Wedge" = "Klín" +"Weight" = "Tloušťka" +"What you try here changes this preview and the code below, not the file" = "Zdejší zkoušení mění tento náhled a kód níže, soubor zůstává beze změny" +"What changed since it was marked ready for dev" = "Co se změnilo od označení jako připravené pro vývoj" +"What is wrong with this file? The server's operator gets your report." = "Co je s tímto souborem v nepořádku? Vaše hlášení obdrží provozovatel serveru." +"What it's for" = "K čemu slouží" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Původní stav není znám: změna nastala dříve, než FigCraft uchovával kopie rámců připravených pro vývoj. Vyřešte změnu (nebo rámec znovu označte jako připravený), aby bylo možné porovnávat budoucí změny." +"While hovering" = "Při najetí ukazatelem" +"While pressing" = "Při stisku" +"White" = "Bílá" +"Who runs it, its terms and how long it keeps files" = "Kdo ho provozuje, jeho podmínky a doba uchovávání souborů" +"Why it changed" = "Důvod změny" +"Widgets" = "Widgety" +"Width" = "Šířka" +"Width point" = "Bod šířky" +"Width profile" = "Profil šířky" +"Wireframe kit" = "Sada pro wireframy" +"With the layers' export settings" = "S nastavením exportu vrstev" +"Wrap" = "Zalamovat" +"Wrap in new section" = "Zabalit do nové sekce" +"Yellow" = "Žlutá" +"Yesterday" = "Včera" +"You" = "Vy" +"You'll get the shared file as a new tab and edit it together." = "Sdílený soubor se otevře v nové kartě a budete ho upravovat společně." +"You're spotlighting: everyone follows your view" = "Sdílíte pohled: všichni sledují vaše zobrazení" +"Your copy of the file stays open" = "Vaše kopie souboru zůstane otevřená" +"Your copy stays open; the file stays on the server" = "Vaše kopie zůstane otevřená; soubor zůstane na serveru" +"Your name" = "Vaše jméno" +"Your name in comments" = "Vaše jméno v komentářích" +"Your system asks for reduced motion: animations stay off." = "Váš systém požaduje omezení pohybu: animace zůstanou vypnuté." +"Z" = "Z" +"Z to A" = "Od Z do A" +"Zoom" = "Přiblížení" +"Zoom around the pointer" = "Přibližovat kolem ukazatele" +"Zoom device to fill window" = "Přiblížit zařízení pro vyplnění okna" +"Zoom in" = "Přiblížit" +"Zoom out" = "Oddálit" +"Zoom to 100%" = "Přiblížit na 100 %" +"Zoom to 200%" = "Přiblížit na 200 %" +"Zoom to 50%" = "Přiblížit na 50 %" +"Zoom to fit" = "Přizpůsobit zobrazení obsahu" +"Zoom to selection" = "Přiblížit na výběr" +"Zoom tool: click to zoom in, drag an area" = "Nástroj přiblížení: kliknutím přiblížíte, tažením vyberete oblast" +"Zoom tool: zoom out" = "Nástroj přiblížení: oddálit" +"and {n} more" = { one = "a {n} další", other = "a další (počet: {n})" } +"bleed marks" = "značky spadávky" +"by {author}" = "autor: {author}" +"call any command its access allows" = "volat libovolný příkaz povolený jeho oprávněními" +"call only: {commands}" = "volat pouze: {commands}" +"can edit the document" = "může upravovat dokument" +"crop marks" = "ořezové značky" +"development: {path}" = "vývoj: {path}" +"e.g. count + 1" = "např. count + 1" +"e.g. count >= 3 and not done" = "např. count >= 3 and not done" +"figcraft:// links from a browser, chat or email open this app and join the session." = "Odkazy figcraft:// z prohlížeče, chatu nebo e-mailu otevřou tuto aplikaci a připojí k relaci." +"from {server}" = "ze serveru {server}" +"hidden" = "skryté" +"iOS" = "iOS" +"just now" = "právě teď" +"locked" = "zamknuté" +"no bleed" = "bez spadávky" +"nobody else here yet" = "zatím tu není nikdo další" +"now" = "nyní" +"or" = "nebo" +"put a widget on the canvas" = "umístit widget na plátno" +"read and edit the document" = "číst a upravovat dokument" +"read the document" = "číst dokument" +"reads the document" = "čte dokument" +"the plugin" = "plugin" +"this session only" = "pouze v této relaci" +"yesterday" = "včera" +"{instances} in {files}" = "{instances} · {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: návrh překladu, čeká na kontrolu rodilým mluvčím." +"{length} bleed" = "Spadávka {length}" +"{month} {day}, {year}" = "{day}. {month} {year}" +"{name} cleared from the list" = "Vymazáno ze seznamu: {name}" +"{name} is forgotten on this computer; the file stays on {server}" = "Zapomenuto v tomto počítači: {name}; soubor zůstává na serveru {server}" +"{name} is on the server." = "{name} je na serveru." +"{name} is spotlighting" = "{name} sdílí pohled" +"{name}\nClick to apply to the selection" = "{name}\nKliknutím použijete na výběr" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPřetáhněte na plátno nebo kliknutím umístěte doprostřed zobrazení.\nPři puštění na instanci podržte Alt (Option) a vyměňte ji." +"{name} (from {library})" = "{name} (z {library})" +"{name} (you)" = "{name} (vy)" +"{name} ({layers} layers)" = "{name} (vrstvy: {layers})" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, výchozí {default}. Dvojklikem na posuvník obnovíte hodnotu stylu; Escape během tažení akci zruší." +"{name} from {path}" = "{name} z {path}" +"{name} was removed from the server" = "Odebráno ze serveru: {name}" +"{name} · deleted on main" = "{name} · smazáno v hlavním souboru" +"{name} · deleted on the branch" = "{name} · smazáno ve větvi" +"{name} · not published" = "{name} · nepublikováno" +"{name} · on a server" = "{name} · na serveru" +"{name} · published version {v}" = "{name} · publikovaná verze {v}" +"{name} · version {v} on the server" = "{name} · verze {v} na serveru" +"{name}, in a live session" = "{name}, v živé relaci" +"{name}, online, open only to you" = "{name}, online, přístupný pouze vám" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, vybráno" +"{n} added" = { one = "Přidáno: {n}", other = "Přidáno: {n}" } +"{n} autosave versions" = { one = "{n} automaticky uložená verze", other = "Automaticky uložené verze: {n}" } +"{n} changed" = { one = "Změněno: {n}", other = "Změněno: {n}" } +"{n} columns" = { one = "{n} sloupec", other = "Sloupce: {n}" } +"{n} components" = { one = "{n} komponenta", other = "Komponenty: {n}" } +"{n} components and styles have newer versions" = { one = "{n} komponenta nebo styl má novější verzi", other = "Komponenty a styly s novější verzí: {n}" } +"{n} conflicts to resolve" = { one = "{n} konflikt k vyřešení", other = "Konflikty k vyřešení: {n}" } +"{n} days ago" = { one = "Před {n} dnem", other = "Před {n} dny" } +"{n} files" = { one = "{n} soubor", other = "Soubory: {n}" } +"{n} hours ago" = { one = "Před {n} hodinou", other = "Před {n} hodinami" } +"{n} instances" = { one = "{n} instance", other = "Instance: {n}" } +"{n} layers" = { one = "{n} vrstva", other = "Vrstvy: {n}" } +"{n} layers as one file" = { one = "{n} vrstva jako jeden soubor", other = "Vrstvy jako jeden soubor: {n}" } +"{n} layers selected" = { one = "Vybrána {n} vrstva", other = "Vybrané vrstvy: {n}" } +"{n} layers · {parts}" = { one = "{n} vrstva · {parts}", other = "Vrstvy: {n} · {parts}" } +"{n} matches" = { one = "{n} shoda", other = "Shody: {n}" } +"{n} minutes ago" = { one = "Před {n} minutou", other = "Před {n} minutami" } +"{n} months ago" = { one = "Před {n} měsícem", other = "Před {n} měsíci" } +"{n} of {total}" = "{n} z {total}" +"{n} people joined" = { one = "Připojena {n} osoba", other = "Připojené osoby: {n}" } +"{n} preferred components" = { one = "{n} preferovaná komponenta", other = "Preferované komponenty: {n}" } +"{n} ready" = { one = "Připraveno: {n}", other = "Připraveno: {n}" } +"{n} removed" = { one = "Odebráno: {n}", other = "Odebráno: {n}" } +"{n} replies" = { one = "{n} odpověď", other = "Odpovědi: {n}" } +"{n} rows" = { one = "{n} řádek", other = "Řádky: {n}" } +"{n} styles" = { one = "{n} styl", other = "Styly: {n}" } +"{n} variables" = { one = "{n} proměnná", other = "Proměnné: {n}" } +"{n} weeks ago" = { one = "Před {n} týdnem", other = "Před {n} týdny" } +"{n} years ago" = { one = "Před {n} rokem", other = "Před {n} lety" } +"{n}d" = "{n} d" +"{n}h" = "{n} h" +"{n}m" = "{n} min" +"{n}w" = "{n} týd." +"{page}, nothing selected" = "{page}, nic nevybráno" +"{profile} (current)" = "{profile} (aktuální)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} dosadí hodnotu; {property ? \"a\" : \"b\"} vybere řetězec. Ostatní {…} zůstává beze změny." +"{server} can't say what became of your files (it is older than this app)." = "{server} nemůže zjistit stav vašich souborů (je starší než tato aplikace)." +"{server} · No longer on the server" = "{server} · Již není na serveru" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Otevřeno {date}" +"{tool} hold {key}" = "{tool} podržte {key}" +"{value} isn't a colour" = "{value} není barva" +"{when} · edited" = "{when} · upraveno" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, mezerník nebo N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ šipky" +"⌘ click" = "⌘ kliknutí" +"⌘ scroll" = "⌘ posouvání" +"⌥ drag" = "⌥ tažení" + +[commands] +"align.bottom" = "Zarovnat dolů" +"align.hcenter" = "Zarovnat vodorovné středy" +"align.left" = "Zarovnat vlevo" +"align.right" = "Zarovnat vpravo" +"align.top" = "Zarovnat nahoru" +"align.vcenter" = "Zarovnat svislé středy" +"annotation.add" = "Přidat anotaci" +"annotation.addCategory" = "Přidat kategorii anotací" +"annotation.categories" = "Kategorie anotací" +"annotation.edit" = "Upravit anotaci" +"annotation.editCategory" = "Upravit kategorii anotací" +"annotation.list" = "Vypsat anotace" +"annotation.remove" = "Odebrat anotaci" +"annotation.removeCategory" = "Smazat kategorii anotací" +"app.actions" = "Akce" +"app.preferences" = "Předvolby" +"arrange.back" = "Přesunout do pozadí" +"arrange.backward" = "Přesunout o úroveň níž" +"arrange.forward" = "Přenést o úroveň výš" +"arrange.front" = "Přenést do popředí" +"arrange.packH" = "Rozmístit těsně vodorovně" +"arrange.packV" = "Rozmístit těsně svisle" +"arrange.roundToPixels" = "Zaokrouhlit na pixely" +"arrange.tidy" = "Uspořádat" +"bool.exclude" = "Vyloučit výběr" +"bool.flatten" = "Sloučit booleovskou skupinu do vektoru" +"bool.intersect" = "Průnik výběru" +"bool.subtract" = "Odečíst výběr" +"bool.union" = "Sjednotit výběr" +"branch.archive" = "Archivovat větev" +"branch.create" = "Vytvořit větev" +"branch.list" = "Vypsat větve" +"branch.merge" = "Sloučit větev" +"branch.open" = "Otevřít větev" +"branch.review" = "Zkontrolovat větev" +"branch.updateFromMain" = "Aktualizovat z hlavního souboru" +"codeConnect.export" = "Exportovat propojení s kódem" +"codeConnect.import" = "Importovat propojení s kódem" +"codeConnect.remove" = "Odpojit od kódu" +"codeConnect.set" = "Propojit s kódem" +"collab.cursor" = "Zobrazit kurzor na pozici" +"collab.cursorChat" = "Chat u kurzoru" +"collab.diff" = "Zobrazit změny synchronizace" +"collab.files" = "Vaše online soubory" +"collab.follow" = "Sledovat" +"collab.forgetFile" = "Zapomenout online soubor" +"collab.host" = "Zahájit živou relaci" +"collab.join" = "Připojit se k živé relaci" +"collab.leave" = "Opustit živou relaci" +"collab.manageFile" = "Spravovat publikovaný soubor" +"collab.openFile" = "Otevřít online soubor" +"collab.peers" = "Lidé v tomto souboru" +"collab.publish" = "Publikovat na server" +"collab.recoverFile" = "Obnovit online soubor" +"collab.registerLinks" = "Otevírat odkazy s pozvánkou v aplikaci FigCraft" +"collab.report" = "Nahlásit soubor provozovateli serveru" +"collab.serverInfo" = "Informace o serveru" +"collab.share" = "Sdílet pomocí kódu pro připojení" +"collab.spotlight" = "Sdílet můj pohled" +"collab.stop" = "Ukončit živou relaci" +"command.batch" = "Spustit více příkazů" +"comment.add" = "Přidat komentář" +"comment.delete" = "Smazat komentář" +"comment.edit" = "Upravit komentář" +"comment.link" = "Kopírovat odkaz na komentář" +"comment.list" = "Vypsat komentáře" +"comment.markRead" = "Označit komentář jako přečtený" +"comment.move" = "Přesunout komentář" +"comment.next" = "Další komentář" +"comment.open" = "Otevřít komentář" +"comment.people" = "Lidé v komentářích" +"comment.react" = "Reagovat na komentář" +"comment.reply" = "Odpovědět na komentář" +"comment.resolve" = "Vyřešit komentář" +"comment.zoom" = "Přiblížit na komentáře" +"component.addProperty" = "Přidat vlastnost komponenty" +"component.addVariant" = "Přidat variantu" +"component.bindProperty" = "Navázat vrstvu na vlastnost" +"component.combineVariants" = "Sloučit jako varianty" +"component.create" = "Vytvořit komponentu" +"component.createMultiple" = "Vytvořit více komponent" +"component.deleteProperty" = "Smazat vlastnost komponenty" +"component.detach" = "Odpojit instanci" +"component.editProperty" = "Upravit vlastnost komponenty" +"component.exposeInstance" = "Zpřístupnit vnořenou instanci" +"component.goToMain" = "Přejít na hlavní komponentu" +"component.hideWhenPublishing" = "Skrýt při publikování" +"component.list" = "Vypsat komponenty" +"component.makeSlot" = "Vytvořit slot" +"component.playground" = "Zkušební prostředí komponenty" +"component.properties" = "Vlastnosti komponenty" +"component.pushChanges" = "Přenést změny do hlavní komponenty" +"component.removeSlot" = "Odebrat slot" +"component.resetInstance" = "Vrátit všechny změny" +"component.resetOverride" = "Zrušit přepsání" +"component.restoreMain" = "Obnovit hlavní komponentu" +"dev.compare" = "Porovnat změny" +"dev.focus" = "Zaměřený pohled" +"dev.resolveChange" = "Vyřešit změnu" +"dev.unfocus" = "Opustit zaměřený pohled" +"devResource.add" = "Přidat zdroj pro vývojáře" +"devResource.edit" = "Upravit zdroj pro vývojáře" +"devResource.list" = "Vypsat zdroje pro vývojáře" +"devResource.remove" = "Odebrat zdroj pro vývojáře" +"distribute.horizontal" = "Rozmístit vodorovné rozestupy" +"distribute.vertical" = "Rozmístit svislé rozestupy" +"doc.home" = "Domů" +"doc.list" = "Vypsat dokumenty" +"doc.nextTab" = "Další karta" +"doc.previousTab" = "Předchozí karta" +"doc.switch" = "Přepnout dokument" +"edit.copy" = "Kopírovat" +"edit.copyAsCss" = "Kopírovat jako CSS" +"edit.copyAsPng" = "Kopírovat jako PNG" +"edit.copyAsSvg" = "Kopírovat jako SVG" +"edit.copyAsText" = "Kopírovat jako text" +"edit.copyLink" = "Kopírovat odkaz na výběr" +"edit.copyProperties" = "Kopírovat vlastnosti" +"edit.cut" = "Vyjmout" +"edit.delete" = "Smazat" +"edit.duplicate" = "Duplikovat" +"edit.findReplace" = "Najít a nahradit" +"edit.paste" = "Vložit" +"edit.pasteOver" = "Vložit přes výběr" +"edit.pasteProperties" = "Vložit vlastnosti" +"edit.pasteReplace" = "Vložit a nahradit" +"edit.pickColor" = "Vybrat barvu" +"edit.redo" = "Znovu" +"edit.repeat" = "Opakovat poslední příkaz" +"edit.setDefaultProperties" = "Nastavit výchozí vlastnosti" +"edit.undo" = "Zpět" +"effect.add" = "Přidat efekt" +"effect.move" = "Přesunout efekt" +"effect.remove" = "Odebrat efekt" +"effect.set" = "Nastavit efekt" +"export.add" = "Přidat nastavení exportu" +"export.remove" = "Odebrat nastavení exportu" +"export.update" = "Upravit nastavení exportu" +"file.close" = "Zavřít kartu" +"file.colorProfile" = "Barevný profil" +"file.duplicate" = "Duplikovat soubor" +"file.export" = "Exportovat" +"file.exportFramesPdf" = "Exportovat rámce do PDF" +"file.exportLayers" = "Exportovat vrstvy" +"file.exportSvg" = "Exportovat výběr jako SVG" +"file.import" = "Importovat" +"file.new" = "Nový soubor návrhu" +"file.newFromTemplate" = "Nový ze šablony" +"file.open" = "Otevřít" +"file.openRecent" = "Otevřít nedávné" +"file.placeImage" = "Umístit obrázek" +"file.recover" = "Obnovit soubor" +"file.rename" = "Přejmenovat soubor" +"file.revert" = "Vrátit k uloženému stavu" +"file.save" = "Uložit" +"file.saveAs" = "Uložit jako" +"file.saveVersion" = "Uložit do historie verzí" +"file.versionHistory" = "Zobrazit historii verzí" +"fill.add" = "Přidat výplň" +"fill.move" = "Přesunout výplň" +"fill.pattern" = "Výplň vzorem" +"fill.remove" = "Odebrat výplň" +"fill.set" = "Nastavit výplň" +"frame.create" = "Vytvořit rámec" +"frame.presets" = "Vypsat předvolby rámců" +"frame.swapOrientation" = "Změnit orientaci" +"grid.add" = "Přidat mřížku rozvržení" +"grid.move" = "Přesunout mřížku rozvržení" +"grid.remove" = "Odebrat mřížku rozvržení" +"guide.add" = "Přidat vodítko" +"guide.clear" = "Odebrat všechna vodítka" +"guide.move" = "Přesunout vodítko" +"guide.remove" = "Odebrat vodítko" +"guide.select" = "Vybrat vodítko" +"help.gettingStarted" = "Začínáme" +"help.about" = "O aplikaci FigCraft" +"help.page" = "Stránka nápovědy" +"help.shortcuts" = "Klávesové zkratky" +"image.adjust" = "Upravit obrázek" +"image.deletePreset" = "Smazat předvolbu obrázku" +"image.place" = "Umístit obrázek" +"image.presets" = "Předvolby obrázků" +"image.savePreset" = "Uložit předvolbu obrázku" +"inspect.assets" = "Prozkoumat zdroje" +"inspect.code" = "Kód vrstvy" +"inspect.componentCode" = "Kód komponenty" +"inspect.properties" = "Prozkoumat vlastnosti" +"inspect.readyForDev" = "Připraveno pro vývoj" +"instance.create" = "Vytvořit instanci" +"instance.exposed" = "Zpřístupněné vnořené instance" +"instance.place" = "Umístit instanci" +"instance.resetSlot" = "Obnovit slot" +"instance.setProperties" = "Nastavit vlastnosti instance" +"instance.slotInsert" = "Vložit do slotu" +"instance.swap" = "Vyměnit instanci" +"instance.swapAt" = "Vyměnit instanci v bodě" +"layers.collapseAll" = "Sbalit vrstvy" +"layout.add" = "Přidat automatické rozvržení" +"layout.remove" = "Odebrat automatické rozvržení" +"layout.setTrack" = "Nastavit velikost stopy mřížky" +"layout.suggest" = "Navrhnout automatické rozvržení" +"libraries.open" = "Knihovny" +"libraries.publish" = "Publikovat knihovnu…" +"library.acceptUpdates" = "Přijmout aktualizace knihovny" +"library.add" = "Přidat knihovnu" +"library.aliasVariable" = "Vytvořit alias proměnné knihovny" +"library.applyStyle" = "Použít styl knihovny" +"library.bindVariable" = "Navázat proměnnou knihovny" +"library.extendCollection" = "Rozšířit kolekci knihovny" +"library.import" = "Importovat komponentu knihovny" +"library.importVariable" = "Importovat proměnnou knihovny" +"library.list" = "Vypsat knihovny" +"library.place" = "Umístit komponentu knihovny" +"library.publish" = "Publikovat knihovnu" +"library.publishToServer" = "Publikovat knihovnu na server" +"library.remove" = "Odebrat knihovnu" +"library.setVariableMode" = "Nastavit režim proměnných knihovny" +"library.swap" = "Vyměnit knihovnu" +"library.swapAt" = "Vyměnit instanci za komponentu knihovny" +"library.updates" = "Aktualizace knihovny" +"library.usage" = "Použití knihovny" +"measure.distances" = "Měřit vzdálenosti" +"measure.padding" = "Měřit vnitřní odsazení automatického rozvržení" +"node.create" = "Vytvořit vrstvu" +"node.get" = "Získat vrstvu" +"node.reorder" = "Změnit pořadí vrstvy" +"node.reparent" = "Přesunout vrstvu do" +"node.set" = "Nastavit vlastnosti" +"object.batchRename" = "Hromadně přejmenovat" +"object.convertToFrame" = "Převést na rámec" +"object.convertToGroup" = "Převést na skupinu" +"object.flipH" = "Převrátit vodorovně" +"object.flipV" = "Převrátit svisle" +"object.frameSelection" = "Vytvořit rámec z výběru" +"object.group" = "Seskupit výběr" +"object.hideOthers" = "Skrýt ostatní vrstvy" +"object.mask" = "Použít jako masku" +"object.moveToPage" = "Přesunout na stránku" +"object.opacityKeys" = "Krytí z číselných kláves" +"object.rasterize" = "Rastrovat výběr" +"object.releaseMask" = "Uvolnit masku" +"object.removeFill" = "Odebrat výplň" +"object.removeFrame" = "Odebrat rámec" +"object.removeStroke" = "Odebrat tah" +"object.rename" = "Přejmenovat" +"object.resizeToFit" = "Přizpůsobit velikost obsahu" +"object.rotate180" = "Otočit o 180°" +"object.rotateLeft" = "Otočit o 90° doleva" +"object.rotateRight" = "Otočit o 90° doprava" +"object.setThumbnail" = "Nastavit jako miniaturu" +"object.swapFillStroke" = "Prohodit výplň a tah" +"object.toggleLock" = "Zamknout/Odemknout výběr" +"object.toggleVisible" = "Zobrazit/Skrýt výběr" +"object.ungroup" = "Zrušit seskupení výběru" +"object.wrapInSection" = "Zabalit do nové sekce" +"page.add" = "Přidat stránku" +"page.delete" = "Smazat stránku" +"page.duplicate" = "Duplikovat stránku" +"page.list" = "Vypsat stránky" +"page.move" = "Přesunout stránku" +"page.rename" = "Přejmenovat stránku" +"page.select" = "Přejít na stránku" +"page.setBackground" = "Pozadí stránky" +"paint.documentColors" = "Barvy dokumentu" +"plugin.close" = "Zavřít panel pluginu" +"plugin.directory.install" = "Nainstalovat plugin z katalogu" +"plugin.directory.list" = "Procházet katalog pluginů" +"plugin.directory.publish" = "Publikovat plugin v katalogu" +"plugin.directory.unpublish" = "Zrušit publikování pluginu v katalogu" +"plugin.event" = "Použít ovládací prvek panelu pluginu" +"plugin.import" = "Importovat plugin z manifestu…" +"plugin.list" = "Vypsat pluginy" +"plugin.remove" = "Odebrat plugin" +"plugin.run" = "Spustit plugin" +"plugin.ui" = "Panel pluginu" +"plugins.manage" = "Spravovat pluginy…" +"plugins.runLast" = "Spustit poslední plugin" +"pref.highlightHover" = "Zvýraznit vrstvy pod ukazatelem" +"pref.keepTool" = "Po použití ponechat nástroj vybraný" +"pref.language" = "Jazyk rozhraní" +"pref.nudge" = "Velikost posunu" +"pref.numberKeysOpacity" = "Nastavovat krytí číselnými klávesami" +"pref.publishServer" = "Server pro online sdílení" +"pref.reduceMotion" = "Omezit pohyb" +"pref.renameDuplicates" = "Přejmenovávat duplikované vrstvy" +"pref.showDimensions" = "Zobrazit rozměry objektů" +"pref.userName" = "Vaše jméno v komentářích" +"proto.action.add" = "Přidat akci prototypu" +"proto.action.move" = "Změnit pořadí akce prototypu" +"proto.action.remove" = "Odebrat akci prototypu" +"proto.action.set" = "Upravit akci prototypu" +"proto.closePreview" = "Zavřít náhled prototypu" +"proto.conditional.addBlock" = "Přidat větev Jinak pokud / Jinak" +"proto.conditional.removeBlock" = "Odebrat větev Pokud / Jinak pokud / Jinak" +"proto.conditional.setCondition" = "Upravit podmínku" +"proto.evaluate" = "Vyhodnotit výraz prototypu" +"proto.flow.add" = "Přidat počáteční bod toku" +"proto.flow.remove" = "Odebrat tok" +"proto.flow.rename" = "Přejmenovat tok" +"proto.interaction.add" = "Přidat interakci" +"proto.interaction.clear" = "Odebrat všechny interakce" +"proto.interaction.remove" = "Odebrat interakci" +"proto.interaction.set" = "Upravit interakci" +"proto.present" = "Prezentovat" +"proto.presentOptions" = "Možnosti prezentace" +"proto.preview" = "Otevřít náhled prototypu" +"proto.settings" = "Nastavení prototypu" +"section.toggleContents" = "Skrýt/Zobrazit obsah sekce" +"select.add" = "Přidat do výběru" +"select.all" = "Vybrat vše" +"select.area" = "Vybrat oblast" +"select.byName" = "Vybrat vrstvy podle názvu" +"select.children" = "Vybrat podřízené vrstvy" +"select.inverse" = "Obrátit výběr" +"select.layersAt" = "Vrstvy v bodě" +"select.matching" = "Vybrat odpovídající vrstvy" +"select.next" = "Vybrat další vrstvu stejné úrovně" +"select.none" = "Zrušit výběr" +"select.parent" = "Vybrat nadřazenou vrstvu" +"select.previous" = "Vybrat předchozí vrstvu stejné úrovně" +"select.remove" = "Odebrat z výběru" +"select.sameEffect" = "Vybrat stejný efekt" +"select.sameFill" = "Vybrat stejnou výplň" +"select.sameFont" = "Vybrat stejné písmo" +"select.sameInstance" = "Vybrat stejnou instanci" +"select.sameProperties" = "Vybrat stejné vlastnosti" +"select.sameStroke" = "Vybrat stejný tah" +"select.sameText" = "Vybrat stejné vlastnosti textu" +"select.set" = "Vybrat" +"shape.arrow" = "Šipka" +"shape.ellipse" = "Elipsa" +"shape.line" = "Čára" +"shape.polygon" = "Mnohoúhelník" +"shape.rectangle" = "Obdélník" +"shape.star" = "Hvězda" +"shapeBuilder.merge" = "Sloučit oblasti" +"shapeBuilder.mode" = "Režim tvorby tvarů" +"shapeBuilder.regions" = "Oblasti tvorby tvarů" +"shapeBuilder.remove" = "Odebrat oblasti" +"stroke.add" = "Přidat tah" +"stroke.brush" = "Štětec" +"stroke.move" = "Přesunout tah" +"stroke.remove" = "Odebrat tah" +"stroke.set" = "Nastavit tah" +"stroke.widthProfile" = "Profil šířky" +"style.apply" = "Použít styl" +"style.bind" = "Navázat proměnnou ve stylu" +"style.create" = "Vytvořit styl" +"style.delete" = "Smazat styl" +"style.detach" = "Odpojit styl" +"style.duplicate" = "Duplikovat styl" +"style.list" = "Vypsat styly" +"style.move" = "Přesunout styly" +"style.renameFolder" = "Přejmenovat složku stylů" +"style.update" = "Upravit styl" +"text.alignCenter" = "Zarovnat na střed" +"text.alignJustify" = "Do bloku" +"text.alignLeft" = "Zarovnat vlevo" +"text.alignRight" = "Zarovnat vpravo" +"text.bold" = "Tučné" +"text.bulletList" = "Odrážkový seznam" +"text.case" = "Velikost písmen" +"text.caseLower" = "Malá písmena" +"text.caseOriginal" = "Původní velikost písmen" +"text.caseSmallCaps" = "Kapitálky" +"text.caseTitle" = "Velká počáteční písmena slov" +"text.caseUpper" = "Velká písmena" +"text.create" = "Text" +"text.decoration" = "Dekorace" +"text.edit.begin" = "Upravit text" +"text.edit.caret" = "Nastavit textový kurzor" +"text.edit.end" = "Ukončit úpravy textu" +"text.edit.insert" = "Psát text" +"text.edit.key" = "Klávesa pro text" +"text.edit.preedit" = "Skládat text (IME)" +"text.edit.selectLine" = "Vybrat řádek" +"text.edit.selectWord" = "Vybrat slovo" +"text.edit.selection" = "Výběr textu" +"text.endPreview" = "Ukončit náhled textu" +"text.fonts" = "Vypsat písma" +"text.italic" = "Kurzíva" +"text.lineHeightDown" = "Zmenšit výšku řádku" +"text.lineHeightUp" = "Zvětšit výšku řádku" +"text.link" = "Vytvořit odkaz" +"text.listIndent" = "Zvětšit odsazení seznamu" +"text.listOutdent" = "Zmenšit odsazení seznamu" +"text.listStyle" = "Styl seznamu" +"text.maxLines" = "Max. řádků" +"text.numberList" = "Číslovaný seznam" +"text.onPath" = "Text na cestě" +"text.paragraphIndent" = "Odsazení odstavce" +"text.paragraphSpacing" = "Mezery mezi odstavci" +"text.pathOptions" = "Možnosti textu na cestě" +"text.preview" = "Náhled změny textu" +"text.removeFromPath" = "Odebrat text z cesty" +"text.set" = "Nastavit text" +"text.setFeatures" = "Funkce OpenType" +"text.setVariations" = "Osy variabilního písma" +"text.sizeDown" = "Zmenšit písmo" +"text.sizeUp" = "Zvětšit písmo" +"text.strikethrough" = "Přeškrtnutí" +"text.style" = "Nastavit styl části textu" +"text.trackingDown" = "Zmenšit prostrkání znaků" +"text.trackingUp" = "Zvětšit prostrkání znaků" +"text.underline" = "Podtržení" +"text.weightDown" = "Zmenšit tloušťku písma" +"text.weightUp" = "Zvětšit tloušťku písma" +"tool.arrow" = "Šipka" +"tool.comment" = "Komentář" +"tool.ellipse" = "Elipsa" +"tool.frame" = "Rámec" +"tool.hand" = "Ruka" +"tool.line" = "Čára" +"tool.move" = "Přesunout" +"tool.pen" = "Pero" +"tool.pencil" = "Tužka" +"tool.pencilBrush" = "Štětec tužky" +"tool.place" = "Umístit pomocí nástroje" +"tool.polygon" = "Mnohoúhelník" +"tool.rectangle" = "Obdélník" +"tool.scale" = "Měřítko" +"tool.scaleAnchor" = "Kotva měřítka" +"tool.section" = "Sekce" +"tool.set" = "Vybrat nástroj" +"tool.shapeBuilder" = "Tvorba tvarů" +"tool.slice" = "Výřez" +"tool.star" = "Hvězda" +"tool.text" = "Text" +"transform.move" = "Přesunout" +"transform.moveTo" = "Přesunout na" +"transform.nudge" = "Posunout" +"transform.nudgeSize" = "Změnit velikost o velikost posunu" +"transform.resize" = "Změnit velikost" +"transform.rotate" = "Otočit" +"transform.rotationOrigin" = "Přesunout střed otáčení" +"transform.scale" = "Měřítko" +"transform.setRotationOrigin" = "Nastavit střed otáčení" +"variable.addMode" = "Přidat režim" +"variable.bind" = "Navázat proměnnou" +"variable.create" = "Vytvořit proměnnou" +"variable.createCollection" = "Vytvořit kolekci proměnných" +"variable.delete" = "Smazat proměnnou" +"variable.deleteCollection" = "Smazat kolekci" +"variable.duplicate" = "Duplikovat proměnnou" +"variable.export" = "Exportovat proměnné" +"variable.import" = "Importovat proměnné" +"variable.list" = "Vypsat proměnné" +"variable.moveMode" = "Přesunout režim" +"variable.removeMode" = "Smazat režim" +"variable.renameCollection" = "Přejmenovat kolekci" +"variable.renameMode" = "Přejmenovat režim" +"variable.resetOverride" = "Zrušit přepsání proměnné" +"variable.set" = "Nastavit hodnotu proměnné" +"variable.setMode" = "Nastavit režim proměnných" +"variable.update" = "Upravit proměnnou" +"vector.create" = "Vektor" +"vector.deleteHeal" = "Smazat a zacelit výběr" +"vector.edit.addVertex" = "Přidat bod" +"vector.edit.begin" = "Upravit vektor" +"vector.edit.bend" = "Ohnout segment" +"vector.edit.bounds" = "Hranice vybraných bodů" +"vector.edit.connect" = "Propojit body" +"vector.edit.cornerRadius" = "Poloměr zaoblení bodu" +"vector.edit.cut" = "Rozříznout cestu" +"vector.edit.delete" = "Smazat body" +"vector.edit.end" = "Dokončit úpravy" +"vector.edit.erase" = "Gumovat" +"vector.edit.lasso" = "Vybrat lasem" +"vector.edit.mirroring" = "Zrcadlení táhel" +"vector.edit.moveHandles" = "Přesunout táhla" +"vector.edit.moveVertices" = "Přesunout body" +"vector.edit.nudge" = "Posunout body" +"vector.edit.paint" = "Plechovka barvy" +"vector.edit.pen" = "Bod pera" +"vector.edit.penEnd" = "Ukončit cestu pera" +"vector.edit.position" = "Poloha bodu" +"vector.edit.resize" = "Změnit velikost výběru bodů" +"vector.edit.rotate" = "Otočit body" +"vector.edit.select" = "Vybrat body" +"vector.edit.selectAll" = "Vybrat všechny body" +"vector.edit.setHandle" = "Nastavit táhlo" +"vector.edit.smooth" = "Vyhladit nebo zostřit body" +"vector.edit.state" = "Stav úprav vektoru" +"vector.edit.strokeCap" = "Zakončení tahu v bodě" +"vector.edit.strokeJoin" = "Spojení tahu v bodě" +"vector.edit.tool" = "Nástroj úprav vektoru" +"vector.edit.transform" = "Transformovat body" +"vector.edit.widthPoint" = "Bod šířky" +"vector.flatten" = "Sloučit do vektoru" +"vector.join" = "Spojit výběr" +"vector.offset" = "Odsadit cestu" +"vector.outlineStroke" = "Převést tah na obrysy" +"vector.reverse" = "Obrátit směr cesty" +"vector.simplify" = "Zjednodušit vektor" +"vector.smoothJoin" = "Hladce spojit výběr" +"version.autosave" = "Automaticky uložit verzi" +"version.delete" = "Smazat verzi" +"version.duplicate" = "Duplikovat verzi" +"version.exitPreview" = "Zpět na aktuální verzi" +"version.list" = "Vypsat verze" +"version.preview" = "Zobrazit verzi" +"version.rename" = "Přejmenovat verzi" +"version.restore" = "Obnovit verzi" +"view.assets" = "Panel zdrojů" +"view.comments" = "Komentáře" +"view.contextMenu" = "Zobrazit kontextovou nabídku" +"view.design" = "Panel Návrh" +"view.designPrototype" = "Přepnout mezi Návrhem a Prototypem" +"view.devMode" = "Režim pro vývojáře" +"view.frameOutlines" = "Obrysy rámců" +"view.get" = "Získat zobrazení" +"view.layers" = "Panel vrstev" +"view.layoutGrids" = "Mřížky rozvržení" +"view.leftSidebar" = "Zobrazit/Skrýt levý postranní panel" +"view.maskOutlines" = "Obrysy masek" +"view.multiplayerCursors" = "Kurzory spolupracovníků" +"view.nextFrame" = "Přiblížit na další rámec" +"view.nextPage" = "Další stránka" +"view.nextRegion" = "Zaměřit další oblast" +"view.outlines" = "Obrysy" +"view.outlinesHiddenLayers" = "Zahrnout skryté vrstvy do obrysů" +"view.outlinesObjectBounds" = "Zahrnout hranice objektů do obrysů" +"view.outlinesSelection" = "Obrysy výběru" +"view.pan" = "Posunout zobrazení" +"view.panelWidths" = "Obnovit šířky panelů" +"view.pixelGrid" = "Pixelová mřížka" +"view.pixelPreview" = "Pixelový náhled" +"view.prevFrame" = "Přiblížit na předchozí rámec" +"view.prevPage" = "Předchozí stránka" +"view.previousRegion" = "Zaměřit předchozí oblast" +"view.proofSrgb" = "Kontrolní náhled sRGB" +"view.fullscreen" = "Celá obrazovka" +"view.prototype" = "Panel Prototyp" +"view.rulers" = "Pravítka" +"view.set" = "Nastavit zobrazení" +"view.slices" = "Výřezy" +"view.snapGeometry" = "Přichytávat ke geometrii" +"view.snapObjects" = "Přichytávat k objektům" +"view.snapPixel" = "Přichytávat k pixelové mřížce" +"view.themeDark" = "Tmavý motiv" +"view.themeLight" = "Světlý motiv" +"view.themeSystem" = "Podle systémového motivu" +"view.toggleUi" = "Zobrazit/Skrýt rozhraní" +"view.zoom100" = "Přiblížit na 100 %" +"view.zoom200" = "Přiblížit na 200 %" +"view.zoom50" = "Přiblížit na 50 %" +"view.zoomAt" = "Nástroj přiblížení" +"view.zoomFit" = "Přizpůsobit zobrazení obsahu" +"view.zoomIn" = "Přiblížit" +"view.zoomOut" = "Oddálit" +"view.zoomSelection" = "Přiblížit na výběr" +"widget.click" = "Kliknout na widget" +"widget.insert" = "Vložit widget" +"widget.list" = "Vypsat widgety" +"window.focus" = "Přenést okno do popředí" +"window.new" = "Nové okno" +"template.list" = "Vypsat šablony" + +[menu] +"bool.exclude" = "Vyloučit" +"bool.flatten" = "Sloučit do vektoru" +"bool.intersect" = "Průnik" +"bool.subtract" = "Odečíst" +"bool.union" = "Sjednotit" +"file.duplicate" = "Duplikovat" +"file.import" = "Importovat ze souboru…" +"file.rename" = "Přejmenovat" +"libraries.open" = "Otevřít knihovny" +"select.sameEffect" = "Efekt" +"select.sameFill" = "Výplň" +"select.sameFont" = "Písmo" +"select.sameInstance" = "Instance" +"select.sameProperties" = "Vlastnosti" +"select.sameStroke" = "Tah" +"select.sameText" = "Vlastnosti textu" +"text.alignCenter" = "Zarovnat text na střed" +"text.alignJustify" = "Zarovnat text do bloku" +"text.alignLeft" = "Zarovnat text vlevo" +"text.alignRight" = "Zarovnat text vpravo" +"view.assets" = "Zdroje" +"view.design" = "Návrh" +"view.layers" = "Vrstvy" +"view.outlines" = "Zobrazit obrysy" +"view.outlinesHiddenLayers" = "Zahrnout skryté vrstvy" +"view.outlinesObjectBounds" = "Zahrnout hranice objektů" +"view.outlinesSelection" = "Zobrazit obrysy výběru" +"view.prototype" = "Prototyp" +"view.themeDark" = "Tmavý" +"view.themeLight" = "Světlý" +"view.themeSystem" = "Podle systému" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 7552cca36..36305e84e 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" @@ -300,7 +301,9 @@ status = "draft — needs native review" "Copy {value}" = "{value} kopieren" "Corner radius" = "Eckenradius" "Corner smoothing" = "Eckenglättung" +"Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." +"Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}" "Count" = "Anzahl" "Create" = "Erstellen" "Create a variable collection first" = "Erstellen Sie zuerst eine Variablensammlung" @@ -456,6 +459,7 @@ status = "draft — needs native review" "Fade" = "Verblassen" "False" = "Falsch" "Fetching from the server…" = "Wird vom Server abgerufen…" +"FigCraft couldn't open its window: {error}" = "FigCraft konnte sein Fenster nicht öffnen: {error}" "File" = "Datei" "File name" = "Dateiname" "File published" = "Datei veröffentlicht" @@ -567,6 +571,7 @@ status = "draft — needs native review" "Hue" = "Farbton" "Hug" = "Umschließen" "Hug contents" = "Inhalt umschließen" +"If it keeps happening, please report it at {link} with this message." = "Wenn es weiterhin passiert, melden Sie es bitte mit dieser Meldung unter {link}." "If" = "Wenn" "If {condition}" = "Wenn {condition}" "Ignore auto layout (absolute position)" = "Auto-Layout ignorieren (absolute Position)" @@ -997,6 +1002,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "„Sonst“ und seine Aktionen entfernen" "Remove this condition and its actions" = "Diese Bedingung und ihre Aktionen entfernen" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Diese Datei vom Server entfernen? Sie kann von dort nicht mehr geteilt oder geöffnet werden. Ihre Kopie bleibt hier geöffnet." +"Remove {name} from session" = "{name} aus der Sitzung entfernen" "Remove {name} from {server}?" = "{name} von {server} entfernen?" "Removed" = "Entfernt" "Removed from the server" = "Vom Server entfernt" @@ -1070,6 +1076,7 @@ status = "draft — needs native review" "Save" = "Speichern" "Save as" = "Speichern unter" "Save as preset…" = "Als Vorgabe speichern…" +"Save files automatically" = "Dateien automatisch speichern" "Save version {key}" = "Version speichern {key}" "Saved" = "Gespeichert" "Saved {name}" = "{name} gespeichert" @@ -1322,6 +1329,8 @@ status = "draft — needs native review" "Update plugin" = "Plugin aktualisieren" "Updated {components} and {styles}" = "{components} und {styles} aktualisiert" "Updated {name}" = "{name} aktualisiert" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Oft hilft es, den Grafiktreiber zu aktualisieren. Um nur OpenGL zu verwenden, starten Sie FigCraft in der Eingabeaufforderung mit:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Oft hilft es, den Grafiktreiber zu aktualisieren. Mit WGPU_BACKEND (vulkan, gl, metal) lässt sich eine andere Grafik-API ausprobieren." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Lädt diese Datei auf einen FigCraft-Server hoch und hält diesen Tab mit ihm synchron. Sie erhalten einen Link zum Bearbeiten und einen zum Ansehen." "Uppercase" = "Großbuchstaben" "Use a fixed value" = "Festen Wert verwenden" @@ -1363,6 +1372,7 @@ status = "draft — needs native review" "Visibility" = "Sichtbarkeit" "Visible" = "Sichtbar" "Vivid" = "Lebhaft" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND ist gesetzt und legt die Grafik-API fest; ohne diese Variable wählt FigCraft selbst." "Warm" = "Warm" "Watch" = "Smartwatch" "Wavy" = "Gewellt" @@ -1568,6 +1578,7 @@ status = "draft — needs native review" "collab.publish" = "Auf Server veröffentlichen" "collab.recoverFile" = "Online-Datei wiederherstellen" "collab.registerLinks" = "Einladungslinks in FigCraft öffnen" +"collab.removePerson" = "Aus der Sitzung entfernen" "collab.report" = "Datei dem Serverbetreiber melden" "collab.serverInfo" = "Serverinformationen" "collab.share" = "Mit Beitrittscode teilen" @@ -1784,6 +1795,7 @@ status = "draft — needs native review" "plugin.ui" = "Plugin-Bereich" "plugins.manage" = "Plugins verwalten…" "plugins.runLast" = "Letztes Plugin ausführen" +"pref.autoSave" = "Dateien automatisch speichern" "pref.highlightHover" = "Ebenen beim Überfahren hervorheben" "pref.keepTool" = "Werkzeug nach Verwendung ausgewählt lassen" "pref.language" = "Sprache der Benutzeroberfläche" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c35b16776..d313c57ee 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" @@ -309,7 +310,9 @@ name = "English" "Copy {value}" = "Copy {value}" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" +"Couldn't download the variables" = "Couldn't download the variables" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." +"Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" "Count" = "Count" "Create" = "Create" "Create a variable collection first" = "Create a variable collection first" @@ -474,6 +477,7 @@ name = "English" "False" = "False" "Feb" = "Feb" "Fetching from the server…" = "Fetching from the server…" +"FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {error}" "File" = "File" "File name" = "File name" "File not found" = "File not found" @@ -593,6 +597,7 @@ name = "English" "Hug contents" = "Hug contents" "Icon components on a 24 px grid with keylines and a size preview" = "Icon components on a 24 px grid with keylines and a size preview" "Icon set starter" = "Icon set starter" +"If it keeps happening, please report it at {link} with this message." = "If it keeps happening, please report it at {link} with this message." "If" = "If" "If {condition}" = "If {condition}" "Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)" @@ -1045,6 +1050,7 @@ name = "English" "Remove the else and its actions" = "Remove the else and its actions" "Remove this condition and its actions" = "Remove this condition and its actions" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." +"Remove {name} from session" = "Remove {name} from session" "Remove {name} from {server}?" = "Remove {name} from {server}?" "Removed" = "Removed" "Removed from the server" = "Removed from the server" @@ -1112,6 +1118,7 @@ name = "English" "Rulers" = "Rulers" "Run" = "Run" "Run {label}" = "Run {label}" +"Save files automatically" = "Save files automatically" "Search templates" = "Search templates" "See all templates" = "See all templates" "Select layers and they space themselves as their content changes" = "Select layers and they space themselves as their content changes" @@ -1388,6 +1395,8 @@ name = "English" "Update from selection" = "Update from selection" "Updated {components} and {styles}" = "Updated {components} and {styles}" "Updated {name}" = "Updated {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." "Uppercase" = "Uppercase" "Use a fixed value" = "Use a fixed value" @@ -1430,6 +1439,7 @@ name = "English" "Visibility" = "Visibility" "Visible" = "Visible" "Vivid" = "Vivid" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." "Warm" = "Warm" "Watch" = "Watch" "Wavy" = "Wavy" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6514ac7be..c9b278462 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,6 +8,7 @@ name = "Español" status = "draft — needs native review" [ui] +"Couldn't download the variables" = "No se pudieron descargar las variables" "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" @@ -17,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}: {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" @@ -311,6 +313,7 @@ status = "draft — needs native review" "Corner radius" = "Radio de esquina" "Corner smoothing" = "Suavizado de esquinas" "Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda." +"Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}" "Count" = "Número" "Create" = "Crear" "Create a variable collection first" = "Crea primero una colección de variables" @@ -475,6 +478,7 @@ status = "draft — needs native review" "False" = "Falso" "Feb" = "feb" "Fetching from the server…" = "Obteniendo del servidor…" +"FigCraft couldn't open its window: {error}" = "FigCraft no pudo abrir su ventana: {error}" "File" = "Archivo" "File name" = "Nombre del archivo" "File not found" = "No se encuentra el archivo" @@ -594,6 +598,7 @@ status = "draft — needs native review" "Hug contents" = "Ajustar al contenido" "Icon components on a 24 px grid with keylines and a size preview" = "Componentes de icono en una cuadrícula de 24 px con líneas guía y una vista de tamaños" "Icon set starter" = "Base de conjunto de iconos" +"If it keeps happening, please report it at {link} with this message." = "Si sigue pasando, infórmalo en {link} con este mensaje." "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)" @@ -1046,6 +1051,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Quitar el «si no» y sus acciones" "Remove this condition and its actions" = "Quitar esta condición y sus acciones" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "¿Quitar este archivo del servidor? No se podrá volver a compartir ni abrir desde allí. Tu copia sigue abierta aquí." +"Remove {name} from session" = "Quitar a {name} de la sesión" "Remove {name} from {server}?" = "¿Quitar {name} de {server}?" "Removed" = "Eliminado" "Removed from the server" = "Quitado del servidor" @@ -1113,6 +1119,7 @@ status = "draft — needs native review" "Rulers" = "Reglas" "Run" = "Ejecutar" "Run {label}" = "Ejecutar {label}" +"Save files automatically" = "Guardar archivos automáticamente" "Search templates" = "Buscar plantillas" "See all templates" = "Ver todas las plantillas" "Select layers and they space themselves as their content changes" = "Selecciona capas y se espaciarán solas cuando cambie su contenido" @@ -1389,6 +1396,8 @@ status = "draft — needs native review" "Update from selection" = "Actualizar desde la selección" "Updated {components} and {styles}" = "Actualizados {components} y {styles}" "Updated {name}" = "{name} actualizado" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Actualizar el controlador gráfico suele ayudar. Para usar solo OpenGL, inicia FigCraft desde el Símbolo del sistema con:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Actualizar el controlador gráfico suele ayudar. WGPU_BACKEND (vulkan, gl, metal) indica otra API gráfica que probar." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Sube este archivo a un servidor FigCraft y mantiene esta pestaña sincronizada con él. Obtienes un enlace para editar y otro para ver." "Uppercase" = "Mayúsculas" "Use a fixed value" = "Usar un valor fijo" @@ -1431,6 +1440,7 @@ status = "draft — needs native review" "Visibility" = "Visibilidad" "Visible" = "Visible" "Vivid" = "Vívido" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica la API gráfica que se usa; si la quitas, FigCraft elige por sí mismo." "Warm" = "Cálido" "Watch" = "Reloj" "Wavy" = "Ondulado" @@ -1643,6 +1653,7 @@ status = "draft — needs native review" "collab.publish" = "Publicar en el servidor" "collab.recoverFile" = "Recuperar archivo en línea" "collab.registerLinks" = "Abrir enlaces de invitación en FigCraft" +"collab.removePerson" = "Quitar de la sesión" "collab.report" = "Denunciar archivo al operador del servidor" "collab.serverInfo" = "Información del servidor" "collab.share" = "Compartir con un código para unirse" @@ -1865,6 +1876,7 @@ status = "draft — needs native review" "plugin.ui" = "Panel del plugin" "plugins.manage" = "Gestionar plugins…" "plugins.runLast" = "Ejecutar el último plugin" +"pref.autoSave" = "Guardar archivos automáticamente" "pref.highlightHover" = "Resaltar capas al pasar el cursor" "pref.keepTool" = "Mantener la herramienta tras usarla" "pref.language" = "Idioma de la interfaz" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index c86659345..7fafd3c91 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" @@ -300,7 +301,9 @@ status = "draft — needs native review" "Copy {value}" = "Copier {value}" "Corner radius" = "Rayon d’angle" "Corner smoothing" = "Lissage des angles" +"Couldn't download the variables" = "Impossible de télécharger les variables" "Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." +"Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}" "Count" = "Nombre" "Create" = "Créer" "Create a variable collection first" = "Créez d’abord une collection de variables" @@ -456,6 +459,7 @@ status = "draft — needs native review" "Fade" = "Fondu" "False" = "Faux" "Fetching from the server…" = "Récupération depuis le serveur…" +"FigCraft couldn't open its window: {error}" = "FigCraft n’a pas pu ouvrir sa fenêtre : {error}" "File" = "Fichier" "File name" = "Nom du fichier" "File published" = "Fichier publié" @@ -567,6 +571,7 @@ status = "draft — needs native review" "Hue" = "Teinte" "Hug" = "Ajuster" "Hug contents" = "Ajuster au contenu" +"If it keeps happening, please report it at {link} with this message." = "Si cela se reproduit, signalez-le sur {link} avec ce message." "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorer la mise en page auto (position absolue)" @@ -997,6 +1002,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Retirer le « sinon » et ses actions" "Remove this condition and its actions" = "Retirer cette condition et ses actions" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Retirer ce fichier du serveur ? Il ne pourra plus être partagé ni ouvert depuis celui-ci. Votre copie reste ouverte ici." +"Remove {name} from session" = "Retirer {name} de la session" "Remove {name} from {server}?" = "Retirer {name} de {server} ?" "Removed" = "Supprimé" "Removed from the server" = "Retiré du serveur" @@ -1070,6 +1076,7 @@ status = "draft — needs native review" "Save" = "Enregistrer" "Save as" = "Enregistrer sous" "Save as preset…" = "Enregistrer comme préréglage…" +"Save files automatically" = "Enregistrer les fichiers automatiquement" "Save version {key}" = "Enregistrer une version {key}" "Saved" = "Enregistré" "Saved {name}" = "{name} enregistré" @@ -1322,6 +1329,8 @@ status = "draft — needs native review" "Update plugin" = "Mettre à jour le plugin" "Updated {components} and {styles}" = "{components} et {styles} mis à jour" "Updated {name}" = "{name} mis à jour" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Mettre à jour le pilote graphique aide souvent. Pour n’utiliser qu’OpenGL, lancez FigCraft depuis l’invite de commandes avec :" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Mettre à jour le pilote graphique aide souvent. WGPU_BACKEND (vulkan, gl, metal) désigne une autre API graphique à essayer." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Envoie ce fichier sur un serveur FigCraft et garde cet onglet synchronisé avec lui. Vous obtenez un lien de modification et un lien de consultation." "Uppercase" = "Majuscules" "Use a fixed value" = "Utiliser une valeur fixe" @@ -1363,6 +1372,7 @@ status = "draft — needs native review" "Visibility" = "Visibilité" "Visible" = "Visible" "Vivid" = "Vif" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND est définie et impose l’API graphique ; sans elle, FigCraft choisit lui-même." "Warm" = "Chaud" "Watch" = "Montre" "Wavy" = "Ondulé" @@ -1568,6 +1578,7 @@ status = "draft — needs native review" "collab.publish" = "Publier sur un serveur" "collab.recoverFile" = "Récupérer un fichier en ligne" "collab.registerLinks" = "Ouvrir les liens d’invitation dans FigCraft" +"collab.removePerson" = "Retirer de la session" "collab.report" = "Signaler le fichier à l’opérateur du serveur" "collab.serverInfo" = "Informations sur le serveur" "collab.share" = "Partager avec un code d’accès" @@ -1784,6 +1795,7 @@ status = "draft — needs native review" "plugin.ui" = "Panneau du plugin" "plugins.manage" = "Gérer les plugins…" "plugins.runLast" = "Exécuter le dernier plugin" +"pref.autoSave" = "Enregistrer les fichiers automatiquement" "pref.highlightHover" = "Mettre en évidence les calques au survol" "pref.keepTool" = "Garder l’outil sélectionné après usage" "pref.language" = "Langue de l’interface" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml new file mode 100644 index 000000000..5de7634d4 --- /dev/null +++ b/crates/ui-egui/locales/id.toml @@ -0,0 +1,2181 @@ +# FigCraft interface strings: Indonesian. +# +# Draft — needs native review: machine-drafted from the English catalog. Brand names and keyboard +# glyphs stay as they are. See docs/translating.md. + +[meta] +name = "Bahasa Indonesia" +status = "draft — needs native review" + +[ui] + +"% of all commits" = "% dari semua commit" +"(no longer published)" = "(tidak lagi dipublikasikan)" +"({n} warnings: some entries were skipped)" = { other = "({n} peringatan: beberapa entri dilewati)" } +"+ Create variable" = "+ Buat variabel" +".fig (experimental)" = ".fig (eksperimental)" +"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} commit, +{added} / −{deleted} baris (Δ {delta}), +{bin_added} / −{bin_deleted} aset biner, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Koleksi file ini dengan mode dan variabel tersebut: timpa nilainya per mode dan tambahkan variabel sendiri" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Satu layer saja diratakan ke frame-nya: pilih lebih banyak layer, atau satu layer di dalam frame" +"A responsive product page: hero, features, figures, a quote and a footer" = "Halaman produk responsif: hero, fitur, angka, kutipan, dan footer" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Spring berjalan hingga stabil: massa, kekakuan, dan redamannya menentukan durasinya" +"A title, an agenda, a big figure, a chart and a closing slide" = "Judul, agenda, angka besar, bagan, dan slide penutup" +"A to Z" = "A sampai Z" +"About" = "Tentang" +"About FigCraft" = "Tentang FigCraft" +"About this server" = "Tentang server ini" +"Action" = "Tindakan" +"Actions" = "Tindakan" +"Actions for {name}" = "Tindakan untuk {name}" +"Actions…" = "Tindakan…" +"Active" = "Aktif" +"Actual size (100%)" = "Ukuran sebenarnya (100%)" +"Add a category" = "Tambah kategori" +"Add a comment" = "Tambah komentar" +"Add a description" = "Tambah deskripsi" +"Add a frame (F) or a shape (R) to get started" = "Tambahkan frame (F) atau bentuk (R) untuk memulai" +"Add a layer or component to tile first" = "Tambahkan dulu layer atau component untuk dijadikan ubin" +"Add a note for developers" = "Tambah catatan untuk developer" +"Add action" = "Tambah aksi" +"Add actions for when no condition holds" = "Tambah aksi saat tidak ada kondisi yang terpenuhi" +"Add an annotation for developers" = "Tambah anotasi untuk developer" +"Add another condition" = "Tambah kondisi lain" +"Add auto layout" = "Tambah auto layout" +"Add background behind overlay" = "Tambah latar belakang di balik overlay" +"Add content to this slot" = "Tambah konten ke slot ini" +"Add dev resource" = "Tambah sumber daya dev" +"Add effect" = "Tambah efek" +"Add export setting" = "Tambah pengaturan ekspor" +"Add fill" = "Tambah fill" +"Add interaction" = "Tambah interaksi" +"Add layout grid" = "Tambah layout grid" +"Add library" = "Tambah pustaka" +"Add library file…" = "Tambah file pustaka…" +"Add max height" = "Tambah tinggi maks" +"Add max width" = "Tambah lebar maks" +"Add min height" = "Tambah tinggi min" +"Add min width" = "Tambah lebar min" +"Add mode" = "Tambah mode" +"Add new page" = "Tambah halaman baru" +"Add or update collections from a variables JSON" = "Tambah atau perbarui koleksi dari JSON variabel" +"Add properties to make instances configurable" = "Tambahkan properti agar instance dapat dikonfigurasi" +"Add reaction" = "Tambah reaksi" +"Add starting point" = "Tambah titik awal" +"Add stroke" = "Tambah stroke" +"Add variant" = "Tambah variant" +"Add variant with a new value" = "Tambah variant dengan nilai baru" +"Added" = "Ditambahkan" +"Added the library \"{name}\"" = "Pustaka \"{name}\" ditambahkan" +"Adjust" = "Sesuaikan" +"Adjustment presets" = "Preset penyesuaian" +"Advanced" = "Lanjutan" +"Advanced stroke" = "Stroke lanjutan" +"After delay" = "Setelah jeda" +"After delay {ms}ms" = "Setelah jeda {ms}ms" +"Align" = "Ratakan" +"Align bottom" = "Ratakan bawah" +"Align center" = "Rata tengah" +"Align horizontal centers" = "Ratakan tengah horizontal" +"Align left" = "Rata kiri" +"Align middle" = "Ratakan tengah vertikal" +"Align right" = "Rata kanan" +"Align text baselines" = "Ratakan garis dasar teks" +"Align top" = "Ratakan atas" +"Align vertical centers" = "Ratakan tengah vertikal" +"Alignment" = "Perataan" +"All" = "Semua" +"All categories" = "Semua kategori" +"All comments" = "Semua komentar" +"All components" = "Semua component" +"All fonts" = "Semua font" +"All supported properties" = "Semua properti yang didukung" +"All variables" = "Semua variabel" +"Alpha" = "Alfa" +"Alphabetical" = "Alfabetis" +"Alt+drag" = "Alt+seret" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Layar analitik pada grid: angka, bagan dari vector, dan tabel" +"An invite link that opens FigCraft and joins" = "Link undangan yang membuka FigCraft dan langsung bergabung" +"Anchor" = "Jangkar" +"Android" = "Android" +"Angular" = "Angular" +"Animation" = "Animasi" +"Annotations" = "Anotasi" +"Annotations shown on the canvas" = "Anotasi yang ditampilkan di kanvas" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Server lain, atau server yang memerlukan kunci. Server default diatur di Preferensi." +"Any status" = "Status apa pun" +"Anyone on your network with this code can edit the file, while this app stays open." = "Siapa pun di jaringan Anda dengan kode ini dapat mengedit file, selama aplikasi ini tetap terbuka." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Siapa pun dengan link edit dapat mengubah file; link lihat hanya memungkinkan orang melihat. Link ini berisi kunci rahasia: bagikan seperti kata sandi. `figcraft-server links` membuat link tambahan dan mencabutnya." +"Anyone with this code can edit the file." = "Siapa pun dengan kode ini dapat mengedit file." +"Anyone with this code can look at the file, not change it." = "Siapa pun dengan kode ini dapat melihat file, tetapi tidak mengubahnya." +"Appearance" = "Tampilan" +"Apply a component property" = "Terapkan properti component" +"Apply variable" = "Terapkan variabel" +"Apply variable to text" = "Terapkan variabel ke teks" +"Apply variable to visibility" = "Terapkan variabel ke visibilitas" +"Apps" = "Aplikasi" +"Apr" = "Apr" +"Arc" = "Busur" +"Arc ratio" = "Rasio busur" +"Arc start" = "Awal busur" +"Arc sweep" = "Sapuan busur" +"Archive" = "Arsipkan" +"Archived" = "Diarsipkan" +"Arrange" = "Susun" +"Arrow" = "Panah" +"As typed" = "Sesuai ketikan" +"Asking the server…" = "Menghubungi server…" +"Assets" = "Aset" +"Aug" = "Agu" +"Auto" = "Otomatis" +"Auto (space around)" = "Otomatis (jarak di sekeliling)" +"Auto (space between)" = "Otomatis (jarak di antara)" +"Auto (space evenly)" = "Otomatis (jarak merata)" +"Auto ({mode})" = "Otomatis ({mode})" +"Auto columns" = "Kolom otomatis" +"Auto gap" = "Jarak otomatis" +"Auto height" = "Tinggi otomatis" +"Auto layout" = "Auto layout" +"Auto rows" = "Baris otomatis" +"Auto width" = "Lebar otomatis" +"Back to the FigCraft server" = "Kembali ke server FigCraft" +"Back to the font's value" = "Kembali ke nilai font" +"Back" = "Kembali" +"Back to Ready for dev" = "Kembali ke Siap untuk dev" +"Back to Ready for dev: the next change compares with how it is now" = "Kembali ke Siap untuk dev: perubahan berikutnya dibandingkan dengan kondisi saat ini" +"Back to assets" = "Kembali ke aset" +"Back to current" = "Kembali ke saat ini" +"Back to styles" = "Kembali ke gaya" +"Back to the canvas from a focused region" = "Kembali ke kanvas dari area fokus" +"Back to the current version" = "Kembali ke versi saat ini" +"Back to the values the font style implies" = "Kembali ke nilai bawaan gaya font" +"Background" = "Latar belakang" +"Background blur" = "Blur latar belakang" +"Basics" = "Dasar" +"Before" = "Sebelum" +"Bend" = "Lengkungan" +"Bevel" = "Bevel" +"Binary assets added" = "Aset biner ditambahkan" +"Binary assets removed" = "Aset biner dihapus" +"Black & white" = "Hitam & putih" +"Bleed" = "Bleed" +"Bleed marks" = "Tanda bleed" +"Bleed marks need a bleed" = "Tanda bleed memerlukan bleed" +"Blend" = "Campuran" +"Blend mode" = "Mode campuran" +"Blue" = "Biru" +"Blur" = "Blur" +"Boolean" = "Boolean" +"Boolean groups" = "Grup boolean" +"Both directions" = "Kedua arah" +"Bottom" = "Bawah" +"Bottom left radius" = "Radius kiri bawah" +"Bottom right radius" = "Radius kanan bawah" +"Branch" = "Branch" +"Branch archived" = "Branch diarsipkan" +"Branch merged" = "Branch di-merge" +"Branch name" = "Nama branch" +"Branch of {file}" = "Branch dari {file}" +"Branch of {file} · merged or archived" = "Branch dari {file} · di-merge atau diarsipkan" +"Branch updated from main" = "Branch diperbarui dari main" +"Branch {name}" = "Branch {name}" +"Branches" = "Branch" +"Bound to {name}. Click to detach the variable." = "Terikat ke {name}. Klik untuk melepas variabel." +"Bring forward" = "Bawa maju" +"Bring the main file's changes into the branch" = "Bawa perubahan file utama ke branch" +"Bring to front" = "Bawa ke paling depan" +"Bristle" = "Bulu kuas" +"Browse" = "Jelajahi" +"Brush" = "Kuas" +"Brush rotation jitter" = "Variasi rotasi kuas" +"Brush scatter" = "Sebaran kuas" +"Brush size jitter" = "Variasi ukuran kuas" +"Brush spacing" = "Jarak kuas" +"Bulleted list" = "Daftar berpoin" +"C or Shift+C" = "C atau Shift+C" +"C or ⇧C" = "C atau ⇧C" +"CMYK colour" = "Warna CMYK" +"Can view" = "Dapat melihat" +"Checking the server…" = "Memeriksa server…" +"Clear from this list" = "Hapus dari daftar ini" +"Code or invite link" = "Kode atau link undangan" +"Color profile: {name}" = "Profil warna: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Gaya warna dan teks, variabel Light dan Dark, tombol, input, dan kartu" +"CSS custom properties…" = "Properti kustom CSS…" +"Can edit" = "Dapat mengedit" +"Can't read this library: {e}" = "Tidak dapat membaca pustaka ini: {e}" +"Cancel" = "Batal" +"Canvas" = "Kanvas" +"Canvas stacking" = "Urutan tumpukan kanvas" +"Cap height to baseline" = "Tinggi kapital ke garis dasar" +"Case" = "Kapitalisasi" +"Case-sensitive forms" = "Bentuk peka huruf besar/kecil" +"Categories" = "Kategori" +"Center" = "Tengah" +"Change alias" = "Ubah alias" +"Change resolved" = "Perubahan diselesaikan" +"Change style" = "Ubah gaya" +"Change to" = "Ubah ke" +"Change to needs a layer inside a variant" = "Ubah ke memerlukan layer di dalam variant" +"Changed" = "Diubah" +"Changed on both sides: keep one" = "Diubah di kedua sisi: pilih salah satu" +"Changes on main" = "Perubahan di main" +"Changes on the branch" = "Perubahan di branch" +"Changes unknown" = "Perubahan tidak diketahui" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Pilih React, Leptos, SwiftUI, atau Compose di atas untuk menghubungkan component ini ke kodenya." +"Choose another profile to change it." = "Pilih profil lain untuk mengubahnya." +"Choose collection" = "Pilih koleksi" +"Choose image" = "Pilih gambar" +"Choose mode" = "Pilih mode" +"Choose variable" = "Pilih variabel" +"Circle" = "Lingkaran" +"Clear recent" = "Hapus daftar terbaru" +"Click + to replace mixed content" = "Klik + untuk mengganti konten campuran" +"Click anywhere on the canvas to leave one." = "Klik di mana saja pada kanvas untuk meninggalkan komentar." +"Click or drag to merge · {alt} removes" = "Klik atau seret untuk menggabungkan · {alt} menghapus" +"Click or drag to remove · {alt} merges" = "Klik atau seret untuk menghapus · {alt} menggabungkan" +"Click to record a key" = "Klik untuk merekam tombol" +"Clip content" = "Potong konten" +"Clip to shape" = "Potong sesuai bentuk" +"Close" = "Tutup" +"Close overlay" = "Tutup overlay" +"Close preview" = "Tutup pratinjau" +"Close tab" = "Tutup tab" +"Close version history" = "Tutup riwayat versi" +"Close when clicking outside" = "Tutup saat klik di luar" +"Close without saving" = "Tutup tanpa menyimpan" +"Code" = "Kode" +"Code syntax" = "Sintaks kode" +"Collapse" = "Ciutkan" +"Collapse panel" = "Ciutkan panel" +"Collection" = "Koleksi" +"Collections" = "Koleksi" +"Color" = "Warna" +"Color burn" = "Color burn" +"Color dodge" = "Color dodge" +"Color format" = "Format warna" +"Color profile" = "Profil warna" +"Color styles" = "Gaya warna" +"Color values in this file are in this color space." = "Nilai warna di file ini menggunakan ruang warna ini." +"Color variables" = "Variabel warna" +"Colour" = "Warna" +"Column" = "Kolom" +"Column gap" = "Jarak kolom" +"Column sizes" = "Ukuran kolom" +"Column span" = "Rentang kolom" +"Columns" = "Kolom" +"Columns {count}" = "Kolom {count}" +"Comment" = "Komentar" +"Comments" = "Komentar" +"Commits" = "Commit" +"Company" = "Perusahaan" +"Compare changes" = "Bandingkan perubahan" +"Completed" = "Selesai" +"Component" = "Component" +"Component name" = "Nama component" +"Component property" = "Properti component" +"Component set" = "Set component" +"Component sets" = "Set component" +"Components" = "Component" +"Conditional" = "Kondisional" +"Confetti" = "Konfeti" +"Conflicts" = "Konflik" +"Connect to code" = "Hubungkan ke kode" +"Connected" = "Terhubung" +"Connected to code: {from}" = "Terhubung ke kode: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Koneksi terputus, menghubungkan ulang (percobaan {attempt})…" +"Constrain proportions" = "Kunci proporsi" +"Constraints" = "Batasan" +"Content" = "Konten" +"Contents hidden" = "Konten disembunyikan" +"Contents only" = "Hanya konten" +"Contextual alternates" = "Alternatif kontekstual" +"Contrast" = "Kontras" +"Contributors" = "Kontributor" +"Contributors: {people} · Commits: {commits}" = "Kontributor: {people} · Commit: {commits}" +"Controller" = "Pengontrol" +"Cool" = "Dingin" +"Copied as CSS" = "Disalin sebagai CSS" +"Copied as PNG" = "Disalin sebagai PNG" +"Copied as SVG" = "Disalin sebagai SVG" +"Copied as text" = "Disalin sebagai teks" +"Copied {value}" = "{value} disalin" +"Copy" = "Salin" +"Copy as" = "Salin sebagai" +"Copy as CSS" = "Salin sebagai CSS" +"Copy as PNG" = "Salin sebagai PNG" +"Copy as SVG" = "Salin sebagai SVG" +"Copy code" = "Salin kode" +"Copy link" = "Salin link" +"Copy the {label}" = "Salin {label}" +"Copy {value}" = "Salin {value}" +"Corner radius" = "Radius sudut" +"Corner smoothing" = "Penghalusan sudut" +"Couldn't reach {server}: showing what this computer remembers." = "Tidak dapat terhubung ke {server}: menampilkan data yang diingat komputer ini." +"Count" = "Jumlah" +"Create" = "Buat" +"Create a variable collection first" = "Buat koleksi variabel terlebih dahulu" +"Create a variable first" = "Buat variabel terlebih dahulu" +"Create alias" = "Buat alias" +"Create branch" = "Buat branch" +"Create collection" = "Buat koleksi" +"Create component" = "Buat component" +"Create component property" = "Buat properti component" +"Create components, styles or variables to publish them as a library." = "Buat component, gaya, atau variabel untuk memublikasikannya sebagai pustaka." +"Create property…" = "Buat properti…" +"Create style" = "Buat gaya" +"Create style from the selected text" = "Buat gaya dari teks yang dipilih" +"Created" = "Dibuat" +"Crop" = "Pangkas" +"Crop marks" = "Tanda potong" +"Ctrl+arrows" = "Ctrl+panah" +"Ctrl+click" = "Ctrl+klik" +"Ctrl+scroll" = "Ctrl+gulir" +"Current name" = "Nama saat ini" +"Current version" = "Versi saat ini" +"Cursor chat" = "Chat kursor" +"Custom" = "Kustom" +"Cut" = "Potong" +"Dark" = "Gelap" +"Darken" = "Gelapkan" +"Dash" = "Putus-putus" +"Dashboard" = "Dasbor" +"Date created" = "Tanggal dibuat" +"Dec" = "Des" +"Decoration style" = "Gaya dekorasi" +"Decoration" = "Dekorasi" +"Deep select" = "Pilih mendalam" +"Default" = "Default" +"Default content" = "Konten default" +"Default value (the top-left variant)" = "Nilai default (variant kiri atas)" +"Delay" = "Penundaan" +"Delete" = "Hapus" +"Delete category (its annotations stay)" = "Hapus kategori (anotasinya tetap ada)" +"Delete collection" = "Hapus koleksi" +"Delete group" = "Hapus grup" +"Delete mode" = "Hapus mode" +"Delete page" = "Hapus halaman" +"Delete preset" = "Hapus preset" +"Delete property" = "Hapus properti" +"Delete the style (layers keep their values)" = "Hapus gaya (layer mempertahankan nilainya)" +"Delete thread" = "Hapus utas" +"Delete variable" = "Hapus variabel" +"Delete “{name}”? This can't be undone." = "Hapus “{name}”? Tindakan ini tidak dapat diurungkan." +"Density" = "Kepadatan" +"Depth" = "Kedalaman" +"Describe how and when to use this component" = "Jelaskan cara dan kapan menggunakan component ini" +"Describe what changed" = "Jelaskan apa yang berubah" +"Description" = "Deskripsi" +"Description (optional)" = "Deskripsi (opsional)" +"Design" = "Desain" +"Design system starter" = "Dasar sistem desain" +"Desktop" = "Desktop" +"Destination" = "Tujuan" +"Detach alias" = "Lepas alias" +"Detach instance" = "Lepaskan instance" +"Detach property" = "Lepas properti" +"Detach style" = "Lepaskan gaya" +"Detach styles" = "Lepas gaya" +"Detach variable" = "Lepas variabel" +"Details" = "Detail" +"Dev Mode" = "Dev Mode" +"Dev resources" = "Sumber daya dev" +"Development" = "Pengembangan" +"Device" = "Perangkat" +"Device at 100%" = "Perangkat pada 100%" +"Diamond" = "Belah ketupat" +"Difference" = "Perbedaan" +"Dimensions" = "Dimensi" +"Direction" = "Arah" +"Disconnect from code" = "Putuskan dari kode" +"Dispersion" = "Dispersi" +"Display name" = "Nama tampilan" +"Dissolve" = "Larutkan" +"Distribute and tidy" = "Sebarkan dan rapikan" +"Distribute horizontal spacing" = "Sebarkan jarak horizontal" +"Distribute vertical spacing" = "Sebarkan jarak vertikal" +"Divider" = "Pembatas" +"Don't truncate" = "Jangan potong" +"Done" = "Selesai" +"Done editing" = "Selesai mengedit" +"Dots" = "Titik" +"Dotted" = "Bertitik" +"Down" = "Bawah" +"Drafts" = "Draf" +"Drag the stroke to add a width point" = "Seret stroke untuk menambah titik lebar" +"Draw" = "Gambar" +"Draw a frame" = "Gambar frame" +"Drawing tools" = "Alat gambar" +"Drop shadow" = "Bayangan luar" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Berlisensi ganda MIT OR Apache-2.0. Dibuat oleh tim ArtCraft dan komunitas." +"Duo" = "Duo" +"Duplicate" = "Duplikat" +"Duplicate by dragging" = "Duplikat dengan menyeret" +"Duplicate mode" = "Mode duplikat" +"Duplicate page" = "Duplikat halaman" +"Edit" = "Edit" +"Edit as an expression" = "Edit sebagai ekspresi" +"Edit colour" = "Edit warna" +"Edit comment" = "Edit komentar" +"Edit dev resource" = "Edit sumber daya dev" +"Edit link" = "Edit link" +"Edit link copied" = "Link edit disalin" +"Edit style" = "Edit gaya" +"Edit variable" = "Edit variabel" +"Edit with the builder" = "Edit dengan builder" +"Edited {when}" = "Diedit {when}" +"Effect settings" = "Pengaturan efek" +"Effect styles" = "Gaya efek" +"Effects" = "Efek" +"Ellipse" = "Elips" +"Ellipses" = "Elips" +"Else" = "Jika tidak" +"Else if" = "Jika tidak, jika" +"Embedded profile" = "Profil tertanam" +"Empty" = "Kosong" +"End" = "Akhir" +"End point" = "Titik akhir" +"Ends the session; everyone keeps their copy" = "Mengakhiri sesi; semua orang tetap memiliki salinannya" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Masukkan alamat figcraft-server lalu tekan Jelajahi untuk melihat plugin yang ditawarkannya." +"Enter to save · Esc to cancel" = "Enter untuk menyimpan · Esc untuk membatalkan" +"Eraser" = "Penghapus" +"Esc" = "Esc" +"Essential" = "Esensial" +"Every conflict has a choice." = "Setiap konflik memiliki pilihan." +"Every top-level frame already starts a flow" = "Setiap frame tingkat atas sudah memulai alur" +"Exact opacity (4 then 5: 45%)" = "Opasitas tepat (4 lalu 5: 45%)" +"Exclude" = "Kecualikan" +"Excluded" = "Dikecualikan" +"Excluded from layout" = "Dikecualikan dari tata letak" +"Excludes" = "Pengecualian" +"Exclusion" = "Eksklusi" +"Exit focus view (Esc)" = "Keluar dari tampilan fokus (Esc)" +"Expand" = "Luaskan" +"Export" = "Ekspor" +"Export all" = "Ekspor semua" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Ekspor frame dan layer sebagai PNG, JPG, SVG, atau PDF" +"Export page as PNG" = "Ekspor halaman sebagai PNG" +"Export settings" = "Pengaturan ekspor" +"Export variables" = "Ekspor variabel" +"Export {name}" = "Ekspor {name}" +"Export {n} layers" = { other = "Ekspor {n} layer" } +"Exported {file}" = "{file} diekspor" +"Exported {name}" = "{name} diekspor" +"Exported {name}; {n} kinds of content were left out" = { other = "{name} diekspor; {n} jenis konten tidak disertakan" } +"Exported {n} files" = { other = "{n} file diekspor" } +"Export…" = "Ekspor…" +"Expose properties to instances" = "Tampilkan properti ke instance" +"Exposure" = "Eksposur" +"Expression" = "Ekspresi" +"Extend collection" = "Perluas koleksi" +"Extends \"{name}\": overridden values are marked" = "Memperluas \"{name}\": nilai yang ditimpa ditandai" +"Eye" = "Mata" +"F" = "F" +"Fade" = "Pudar" +"False" = "Salah" +"Feb" = "Feb" +"Fetching from the server…" = "Mengambil dari server…" +"File" = "File" +"File name" = "Nama file" +"File not found" = "File tidak ditemukan" +"File published" = "File dipublikasikan" +"Files aren't end-to-end encrypted: the server's operator can read them." = "File tidak dienkripsi end-to-end: operator server dapat membacanya." +"Files nobody opens for {n} days are deleted." = { other = "File yang tidak dibuka siapa pun selama {n} hari akan dihapus." } +"Fill" = "Fill" +"Fill (fr)" = "Fill (fr)" +"Fill container" = "Isi kontainer" +"Fill window" = "Isi jendela" +"Fills" = "Fill" +"Fill|sizing" = "Isi" +"Filter and sort" = "Filter dan urutkan" +"Find" = "Cari" +"Find and replace" = "Cari dan ganti" +"Find layers" = "Cari layer" +"First" = "Pertama" +"First commit" = "Commit pertama" +"First on top" = "Pertama di atas" +"Fit" = "Sesuaikan" +"Fit device to window" = "Sesuaikan perangkat dengan jendela" +"Fit to contents" = "Sesuaikan dengan konten" +"Fit to window" = "Sesuaikan dengan jendela" +"Fit width" = "Sesuaikan lebar" +"Fixed" = "Tetap" +"Fixed gap" = "Jarak tetap" +"Fixed size" = "Ukuran tetap" +"Flatten" = "Satukan" +"Flip" = "Balik" +"Flip horizontal" = "Balik horizontal" +"Flip text to the other side" = "Balik teks ke sisi lain" +"Flip vertical" = "Balik vertikal" +"Flip width profile" = "Balik profil lebar" +"Flow starting point" = "Titik awal alur" +"Flow starting points" = "Titik awal alur" +"Flows" = "Alur" +"Focus view" = "Tampilan fokus" +"Fold autosaves" = "Ciutkan simpanan otomatis" +"Folder: click to collapse" = "Folder: klik untuk menciutkan" +"Folder: click to expand" = "Folder: klik untuk meluaskan" +"Follow" = "Ikuti" +"Follow {name}" = "Ikuti {name}" +"Following" = "Mengikuti" +"Following {name} · {key} to stop" = "Mengikuti {name} · {key} untuk berhenti" +"Font" = "Font" +"Font not installed" = "Font tidak terinstal" +"Font size" = "Ukuran font" +"Font style" = "Gaya font" +"Forget on this computer" = "Lupakan di komputer ini" +"Four screens of a trail app with components and a prototype flow" = "Empat layar aplikasi jalur pendakian dengan component dan alur prototype" +"Fractions" = "Pecahan" +"Frame" = "Frame" +"Frame selection" = "Buat frame dari pilihan" +"Frame tools" = "Alat frame" +"Frames" = "Frame" +"From the library \"{name}\": read-only" = "Dari pustaka \"{name}\": hanya baca" +"From {name}" = "Dari {name}" +"From {server}" = "Dari {server}" +"Frost" = "Embun beku" +"Gap" = "Jarak" +"Gap between columns" = "Jarak antar kolom" +"Gap between items" = "Jarak antar item" +"Gap between rows" = "Jarak antar baris" +"Getting started" = "Memulai" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Berikan alamat server yang terbuka untuk publik, atau alamat server beserta kuncinya (`figcraft-server key` menampilkannya)." +"Glass" = "Kaca" +"Go to main component" = "Buka main component" +"Go to {name}" = "Buka {name}" +"Grab bag" = "Campuran" +"Gradient" = "Gradien" +"Gray" = "Abu-abu" +"Green" = "Hijau" +"Greyscale components and two example screens to sketch a flow with" = "Component skala abu-abu dan dua layar contoh untuk membuat sketsa alur" +"Grid" = "Grid" +"Grid ({size})" = "Grid ({size})" +"Grid styles" = "Gaya grid" +"Grid {size}" = "Grid {size}" +"Group" = "Grup" +"Group selection" = "Grupkan pilihan" +"Groups" = "Grup" +"Gutter" = "Gutter" +"Hand tool" = "Alat tangan" +"Hanging list" = "Daftar menggantung" +"Hanging punctuation" = "Tanda baca menggantung" +"Hard light" = "Hard light" +"Height" = "Tinggi" +"Help" = "Bantuan" +"Help and resources" = "Bantuan dan sumber daya" +"Help page" = "Halaman bantuan" +"Here: {names}" = "Di sini: {names}" +"Hidden" = "Tersembunyi" +"Hide contents" = "Sembunyikan konten" +"Hide from publishing" = "Sembunyikan dari publikasi" +"Hide section contents" = "Sembunyikan konten section" +"Hide these tips (Getting started in the Help menu shows them again)" = "Sembunyikan tips ini (tampilkan lagi lewat Memulai di menu Bantuan)" +"Hide when publishing" = "Sembunyikan saat dipublikasikan" +"Highlight layers on hover" = "Sorot layer saat hover" +"Highlights" = "Sorotan" +"Hold Alt" = "Tahan Alt" +"Hold Alt+Z" = "Tahan Alt+Z" +"Hold Ctrl" = "Tahan Ctrl" +"Hold Z" = "Tahan Z" +"Hold ⌃ or ⌘" = "Tahan ⌃ atau ⌘" +"Hold ⌥" = "Tahan ⌥" +"Hold ⌥Z" = "Tahan ⌥Z" +"Home" = "Beranda" +"Horizontal" = "Horizontal" +"Horizontal align" = "Perataan horizontal" +"Horizontal hex" = "Heksagon horizontal" +"Horizontal padding" = "Padding horizontal" +"Horizontal spacing" = "Jarak horizontal" +"Hosted by {operator}" = "Di-host oleh {operator}" +"Hover a layer to see its properties" = "Arahkan kursor ke layer untuk melihat propertinya" +"Hover other layers to measure the distance" = "Arahkan kursor ke layer lain untuk mengukur jarak" +"Hue" = "Hue" +"Hug" = "Hug" +"Hug contents" = "Hug konten" +"Icon components on a 24 px grid with keylines and a size preview" = "Component ikon pada grid 24 px dengan keyline dan pratinjau ukuran" +"Icon set starter" = "Set ikon awal" +"If" = "Jika" +"If {condition}" = "Jika {condition}" +"Ignore auto layout (absolute position)" = "Abaikan auto layout (posisi absolut)" +"Image" = "Gambar" +"Import" = "Impor" +"Import path" = "Jalur impor" +"Import plugin from manifest" = "Impor plugin dari manifest" +"Import plugin from manifest…" = "Impor plugin dari manifest…" +"Import variables" = "Impor variabel" +"Imported {name}" = "{name} diimpor" +"Imported {n} new variables, updated {updated}" = { other = "{n} variabel baru diimpor, {updated} diperbarui" } +"Import…" = "Impor…" +"In an instance, this comes from the main component" = "Pada instance, ini berasal dari main component" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "Di browser, Anda dapat bergabung ke sesi yang di-host aplikasi desktop dan file di server FigCraft. Halaman yang dibuka lewat https hanya dapat menjangkau komputer lain melalui link wss:// (server di balik TLS)." +"Include bounding box" = "Sertakan kotak pembatas" +"Include id attribute" = "Sertakan atribut id" +"Included" = "Disertakan" +"Included in layout" = "Disertakan dalam tata letak" +"Individual corners" = "Sudut individual" +"Individual strokes" = "Stroke individual" +"Ink" = "Tinta" +"Inner shadow" = "Bayangan dalam" +"Inside" = "Di dalam" +"Inspect" = "Inspeksi" +"Inspect the layer under the pointer: size, colours, typography" = "Inspeksi layer di bawah pointer: ukuran, warna, tipografi" +"Install plugin" = "Instal plugin" +"Install" = "Instal" +"Installed {name}" = "{name} diinstal" +"Installed" = "Terinstal" +"Installing…" = "Menginstal…" +"Instance" = "Instance" +"Instance properties" = "Properti instance" +"Instance swap" = "Tukar instance" +"Instance swap (no other component)" = "Tukar instance (tidak ada component lain)" +"Instances" = "Instance" +"Instant" = "Instan" +"Interaction" = "Interaksi" +"Interactions" = "Interaksi" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Desain antarmuka dan prototyping. Alat desain clean-room gratis dan open-source yang ditulis dari nol dengan Rust, bagian dari ArtCraft Crafting Apps." +"Intersect" = "Irisan" +"Intersects" = "Beririsan" +"Invite link" = "Link undangan" +"Invite link copied" = "Link undangan disalin" +"It asks permission to:" = "Plugin ini meminta izin untuk:" +"Jan" = "Jan" +"Join" = "Gabung" +"Join code copied" = "Kode gabung disalin" +"Join code or invite link" = "Kode gabung atau link undangan" +"Join live session" = "Gabung sesi langsung" +"Join us on Discord" = "Gabung dengan kami di Discord" +"Join with a code" = "Gabung dengan kode" +"Joined" = "Bergabung" +"Joined {file}" = "Bergabung ke {file}" +"Joining…" = "Bergabung…" +"Join|stroke" = "Sambungan" +"Jul" = "Jul" +"Jun" = "Jun" +"Just now" = "Baru saja" +"Justify" = "Rata kiri-kanan" +"Keep appearance" = "Pertahankan tampilan" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Pertahankan tampilan mengonversi setiap nilai warna ke {profile} agar warna terlihat sama. Pertahankan nilai membiarkan angkanya apa adanya, sehingga warna bergeser." +"Keep tool selected after use" = "Tetap pilih alat setelah dipakai" +"Keep values" = "Pertahankan nilai" +"Key/Gamepad" = "Tombol/Gamepad" +"Key/Gamepad {key}" = "Tombol/Gamepad {key}" +"Keyboard shortcuts" = "Pintasan keyboard" +"Kits" = "Kit" +"Landing page" = "Landing page" +"Language" = "Bahasa" +"Lasso" = "Laso" +"Last" = "Terakhir" +"Last change: {note}" = "Perubahan terakhir: {note}" +"Last commit" = "Commit terakhir" +"Last modified" = "Terakhir diubah" +"Last on top" = "Terakhir di atas" +"Layer blur" = "Blur layer" +"Layer name" = "Nama layer" +"Layers" = "Layer" +"Layers in an instance take their position from the main component" = "Layer dalam instance mengambil posisinya dari main component" +"Layout" = "Tata letak" +"Layout grid" = "Grid tata letak" +"Layout grid settings" = "Pengaturan grid tata letak" +"Layout grids" = "Grid tata letak" +"Leave session" = "Tinggalkan sesi" +"Leaves" = "Daun" +"Left" = "Kiri" +"Left & right" = "Kiri & kanan" +"Left side" = "Sisi kiri" +"Left side width" = "Lebar sisi kiri" +"Letter case" = "Huruf besar/kecil" +"Letter spacing" = "Spasi huruf" +"Libraries" = "Pustaka" +"Libraries in this file" = "Pustaka di file ini" +"Library updates available" = "Pembaruan pustaka tersedia" +"Library: click to collapse" = "Pustaka: klik untuk menciutkan" +"Library: click to expand" = "Pustaka: klik untuk meluaskan" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Pustaka: variabelnya hanya-baca di sini; edit di file pustaka lalu publikasikan" +"Light" = "Terang" +"Lighten" = "Lighten" +"Light|glass" = "Cahaya" +"Line" = "Garis" +"Line arrow" = "Garis panah" +"Line delta" = "Selisih baris" +"Line height" = "Tinggi baris" +"Linear" = "Linear" +"Lines" = "Baris" +"Lines +/−" = "Baris +/−" +"Lines added" = "Baris ditambahkan" +"Lines deleted" = "Baris dihapus" +"Link" = "Link" +"Link copied" = "Link disalin" +"Link docs, code or tickets for developers." = "Tautkan dokumen, kode, atau tiket untuk developer." +"Link frames on the Prototype tab, then present the flow" = "Tautkan frame di tab Prototype, lalu presentasikan alurnya" +"Link this component to its code component: instances then show your real snippet with their values." = "Tautkan component ini ke component kodenya: instance akan menampilkan snippet asli Anda beserta nilainya." +"List spacing" = "Jarak daftar" +"List style" = "Gaya daftar" +"Live session" = "Sesi langsung" +"Live · {people}" = "Langsung · {people}" +"Local components" = "Component lokal" +"Local network" = "Jaringan lokal" +"Local styles" = "Gaya lokal" +"Local variables" = "Variabel lokal" +"Lock/Unlock" = "Kunci/Buka kunci" +"Locked" = "Terkunci" +"Lowercase" = "Huruf kecil" +"Luminance" = "Luminans" +"Luminosity" = "Luminositas" +"Main" = "Utama" +"Main component deleted" = "Main component dihapus" +"Main menu" = "Menu utama" +"Make a component" = "Buat component" +"Make slot" = "Jadikan slot" +"Mar" = "Mar" +"Margin" = "Margin" +"Mark all as read" = "Tandai semua sudah dibaca" +"Mark as resolved" = "Tandai selesai" +"Mark as unread" = "Tandai belum dibaca" +"Mark as unresolved" = "Tandai belum selesai" +"Mask" = "Mask" +"Mass · Stiffness · Damping" = "Massa · Kekakuan · Redaman" +"Match (empty: the whole name)" = "Cocokkan (kosong: seluruh nama)" +"Max H" = "Maks T" +"Max W" = "Maks L" +"Max lines" = "Baris maksimum" +"Maximize" = "Maksimalkan" +"May" = "Mei" +"Measure to the selection" = "Ukur ke pilihan" +"Merge into main" = "Merge ke main" +"Merge regions" = "Gabungkan area" +"Merge the branch into the main file (one undo step) and archive it" = "Merge branch ke file utama (satu langkah urungkan) lalu arsipkan" +"Merged" = "Di-merge" +"Merged PRs" = "PR yang di-merge" +"Min H" = "Min T" +"Min W" = "Min L" +"Minimize" = "Minimalkan" +"Mirror angle" = "Cerminkan sudut" +"Mirror angle and length" = "Cerminkan sudut dan panjang" +"Mirroring" = "Pencerminan" +"Missing" = "Hilang" +"Missing layer" = "Layer hilang" +"Missing style" = "Gaya hilang" +"Missing variable" = "Variabel hilang" +"Miter" = "Miter" +"Miter angle" = "Sudut miter" +"Mixed" = "Campuran" +"Mixed fonts" = "Font campuran" +"Mixed interactions: click + to replace them" = "Interaksi campuran: klik + untuk menggantinya" +"Mobile app" = "Aplikasi seluler" +"Mode" = "Mode" +"Model" = "Model" +"Models" = "Model" +"Mono" = "Mono" +"More" = "Lainnya" +"More instance actions" = "Tindakan instance lainnya" +"Mouse down" = "Mouse down" +"Mouse enter" = "Mouse enter" +"Mouse leave" = "Mouse leave" +"Mouse up" = "Mouse up" +"Move along the toolbar or the layer rows" = "Berpindah di toolbar atau baris layer" +"Move" = "Pindahkan" +"Move column left" = "Pindahkan kolom ke kiri" +"Move column right" = "Pindahkan kolom ke kanan" +"Move down" = "Pindahkan ke bawah" +"Move in" = "Geser masuk" +"Move out" = "Geser keluar" +"Move tools" = "Alat pindah" +"Move up" = "Pindahkan ke atas" +"Moved to {name}" = "Dipindahkan ke {name}" +"Multi" = "Multi" +"Multiply" = "Multiply" +"Muted" = "Dibisukan" +"Name" = "Nama" +"Name (A–Z)" = "Nama (A–Z)" +"Named instance" = "Instance bernama" +"Navigate to" = "Navigasi ke" +"Nested instance" = "Instance bersarang" +"New design file" = "File desain baru" +"Newest first" = "Terbaru dulu" +"Next or previous control in the focused region" = "Kontrol berikutnya atau sebelumnya di wilayah yang difokuskan" +"Next ready for dev" = "Siap dev berikutnya" +"Next size option" = "Opsi ukuran berikutnya" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Tidak ada aset: tidak ada pengaturan ekspor, ikon, atau gambar. Tambahkan pengaturan ekspor di tab Desain." +"No boolean variables fit here." = "Tidak ada variabel boolean yang cocok di sini." +"No boolean variables yet. Create them in Local variables." = "Belum ada variabel boolean. Buat di Variabel lokal." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Tidak ada branch. Branch adalah salinan file untuk mencoba perubahan, lalu ditinjau dan di-merge." +"No category" = "Tanpa kategori" +"No changes" = "Tidak ada perubahan" +"No colour variables fit here." = "Tidak ada variabel warna yang cocok di sini." +"No colour variables yet. Create them in Local variables." = "Belum ada variabel warna. Buat di Variabel lokal." +"No comments yet" = "Belum ada komentar" +"No components yet. Select layers and press {keys}." = "Belum ada component. Pilih layer lalu tekan {keys}." +"No conflicts." = "Tidak ada konflik." +"No contributor data was built into this copy." = "Tidak ada data kontributor yang disertakan dalam salinan ini." +"No fonts match" = "Tidak ada font yang cocok" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Tidak ada kode gabung di sini (versi server lebih lama dari fitur kode gabung, atau alamat ini tidak dapat dimasukkan ke kode): bagikan link-nya saja." +"No layer changes to show." = "Tidak ada perubahan layer untuk ditampilkan." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Belum ada pustaka. Tambahkan file .figcraft yang dipublikasikan sebagai pustaka, atau tempel link pustaka tim." +"No list" = "Tanpa daftar" +"No matching comments" = "Tidak ada komentar yang cocok" +"No matching components" = "Tidak ada component yang cocok" +"No matching layers" = "Tidak ada layer yang cocok" +"No matching styles" = "Tidak ada gaya yang cocok" +"No matching variables" = "Tidak ada variabel yang cocok" +"No mirroring" = "Tanpa pencerminan" +"No model credits were built into this copy." = "Tidak ada kredit model yang disertakan dalam salinan ini." +"No number variables fit here." = "Tidak ada variabel angka yang cocok di sini." +"No number variables yet. Create them in Local variables." = "Belum ada variabel angka. Buat di Variabel lokal." +"No paints" = "Tidak ada paint" +"No plugins on {server}." = "Tidak ada plugin di {server}." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Belum ada plugin. Impor plugin pengembangan dari file figcraft-plugin.json." +"No preferred components" = "Tidak ada component yang diutamakan" +"No preset" = "Tanpa preset" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Belum ada versi tersimpan. Simpan versi untuk menandai titik kerja yang bisa Anda kunjungi lagi." +"No scrolling" = "Tanpa scroll" +"No shortcuts match" = "Tidak ada pintasan yang cocok" +"No status" = "Tanpa status" +"No string variables fit here." = "Tidak ada variabel string yang sesuai di sini." +"No string variables yet. Create them in Local variables." = "Belum ada variabel string. Buat di Variabel lokal." +"No styles yet." = "Belum ada gaya." +"No styles yet. Click + to create one from the selection." = "Belum ada gaya. Klik + untuk membuat dari pilihan." +"No templates match your search" = "Tidak ada templat yang cocok dengan pencarian Anda" +"No text styles yet. Click + to create one from the selected text." = "Belum ada gaya teks. Klik + untuk membuat dari teks yang dipilih." +"No variables fit here." = "Tidak ada variabel yang sesuai di sini." +"No variables yet. Create them in Local variables." = "Belum ada variabel. Buat di Variabel lokal." +"Nobody else is here yet" = "Belum ada orang lain di sini" +"Noise" = "Noise" +"None" = "Tidak ada" +"None." = "Tidak ada." +"Normal" = "Normal" +"Not bound" = "Tidak terikat" +"Not in a live session" = "Tidak dalam sesi langsung" +"Not in this font" = "Tidak ada di font ini" +"Not properties, kept as written: {names}" = "Bukan properti, dibiarkan apa adanya: {names}" +"Not read yet" = "Belum dibaca" +"Not shared" = "Tidak dibagikan" +"Note {n}" = "Catatan {n}" +"Nothing changed since this was marked ready for dev." = "Tidak ada perubahan sejak ditandai siap untuk dev." +"Nothing here yet. Set a frame's status to list it." = "Belum ada apa pun. Atur status frame untuk menampilkannya." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Belum ada yang online. Gunakan Bagikan pada file agar file online dan tercantum di sini." +"Nothing to export" = "Tidak ada yang bisa diekspor" +"Nov" = "Nov" +"Now" = "Sekarang" +"Nudge (Shift: big nudge)" = "Geser sedikit (Shift: geser jauh)" +"Nudge amount" = "Jarak geser" +"Number" = "Angka" +"Number ↑" = "Angka ↑" +"Number ↓" = "Angka ↓" +"Numbered list" = "Daftar bernomor" +"Object" = "Objek" +"Oct" = "Okt" +"Off" = "Nonaktif" +"Offered first when filling the slot (any content is allowed)" = "Ditawarkan pertama saat mengisi slot (konten apa pun diizinkan)" +"Offered first when swapping the instance (any component is allowed)" = "Ditawarkan pertama saat menukar instance (component apa pun diizinkan)" +"Offset" = "Offset" +"Oldest first" = "Terlama dulu" +"On click" = "Saat diklik" +"On drag" = "Saat diseret" +"On this page" = "Di halaman ini" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Satu pengumuman dalam lima ukuran umum: persegi, potret, story, link, dan banner" +"Online" = "Online" +"Online files" = "File online" +"Online sharing is off: files are shared on the local network only." = "Berbagi online nonaktif: file hanya dibagikan di jaringan lokal." +"Only the first changed layers are listed." = "Hanya beberapa layer pertama yang berubah yang dicantumkan." +"Only the first {n} changed layers are listed." = "Hanya {n} layer pertama yang berubah yang dicantumkan." +"Only your threads" = "Hanya utas Anda" +"Opacity" = "Opasitas" +"Opacity & blend" = "Opasitas & campuran" +"Opacity 10%…90%, 0: 100%" = "Opasitas 10%…90%, 0: 100%" +"Open" = "Buka" +"Open .figcraft, .svg or images" = "Buka .figcraft, .svg, atau gambar" +"Open branch" = "Buka branch" +"Open file" = "Buka file" +"Open files" = "Buka file" +"Open in focus view" = "Buka di tampilan fokus" +"Open in new window" = "Buka di jendela baru" +"Open invite links in FigCraft" = "Buka link undangan di FigCraft" +"Open link" = "Buka link" +"Open link {url}" = "Buka link {url}" +"Open overlay" = "Buka overlay" +"Open prototype preview" = "Buka pratinjau prototype" +"Open variables ({count})" = "Buka variabel ({count})" +"Opened “{name}” in a new tab" = "“{name}” dibuka di tab baru" +"Open|comments" = "Terbuka" +"Options" = "Opsi" +"Orange" = "Oranye" +"Order" = "Urutan" +"Other apps may not open every .fig file exported here." = "Aplikasi lain mungkin tidak dapat membuka setiap file .fig yang diekspor di sini." +"Others lose access and keep their copy; the file stays on the server for you" = "Orang lain kehilangan akses tetapi tetap memiliki salinannya; file tetap di server untuk Anda" +"Others type this under Share ▸ Join with a code" = "Orang lain mengetik ini di Bagikan ▸ Gabung dengan kode" +"Outline stroke" = "Outline stroke" +"Outline text" = "Outline teks" +"Outlines" = "Garis luar" +"Outside" = "Luar" +"Overflow" = "Overflow" +"Overlay" = "Overlay" +"Overlay position" = "Posisi overlay" +"Overridden here (the collection it extends has another value)" = "Ditimpa di sini (koleksi yang diperluasnya memiliki nilai lain)" +"Overrides" = "Override" +"Owner token" = "Token pemilik" +"Owner token copied" = "Token pemilik disalin" +"PDF print options" = "Opsi cetak PDF" +"PDF print settings" = "Pengaturan cetak PDF" +"Padding" = "Padding" +"Page" = "Halaman" +"Pages" = "Halaman" +"Pages: {pages}" = "Halaman: {pages}" +"Paint bucket" = "Ember cat" +"Pan the canvas" = "Geser kanvas" +"Pan with nothing selected (Shift: farther)" = "Geser tanpa ada yang dipilih (Shift: lebih jauh)" +"Panels" = "Panel" +"Paper" = "Kertas" +"Paragraph indent" = "Indentasi paragraf" +"Paragraph indent applies to left-aligned text" = "Inden paragraf berlaku untuk teks rata kiri" +"Paragraph spacing" = "Spasi paragraf" +"Pass through" = "Pass through" +"Paste a link (https://…)" = "Tempel link (https://…)" +"Paste a team library link (a view link)" = "Tempel link pustaka tim (link akses lihat)" +"Paste here" = "Tempel di sini" +"Paste to replace" = "Tempel dan ganti" +"Pattern" = "Pola" +"Pattern alignment" = "Perataan pola" +"Pattern scale" = "Skala pola" +"Pattern source" = "Sumber pola" +"Pen" = "Pena" +"Pencil" = "Pensil" +"People in this file" = "Orang di file ini" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Orang bergabung dari mana saja dengan kode. File disimpan di {server} selama Anda membagikannya." +"People on your network join with a code. Nothing goes through a server." = "Orang di jaringan Anda bergabung dengan kode. Tidak ada yang melewati server." +"Phone" = "Ponsel" +"Pick color" = "Ambil warna" +"Pick color (I)" = "Ambil warna (I)" +"Pick one font family first" = "Pilih satu keluarga font dulu" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Pilih alat Frame, lalu seret di kanvas atau pilih ukuran perangkat" +"Pin a property" = "Sematkan properti" +"Pinch" = "Cubit" +"Pixel grid" = "Grid piksel" +"Pixel preview 1x" = "Pratinjau piksel 1x" +"Pixel preview 2x" = "Pratinjau piksel 2x" +"Place a shape or frame (its tool chosen)" = "Tempatkan bentuk atau frame (saat alatnya dipilih)" +"Place automatically" = "Tempatkan otomatis" +"Place image" = "Tempatkan gambar" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugin berjalan di sandbox FigCraft, tanpa file, jaringan, atau jam. Pasang hanya plugin yang Anda percayai." +"Plugins" = "Plugin" +"Plus darker" = "Plus darker" +"Plus lighter" = "Plus lighter" +"Point" = "Titik" +"Points" = "Titik" +"Polygon" = "Poligon" +"Polygons" = "Poligon" +"Port" = "Port" +"Position" = "Posisi" +"Post" = "Kirim" +"Preferences" = "Preferensi" +"Preferred" = "Diutamakan" +"Present" = "Presentasikan" +"Present or preview" = "Presentasikan atau pratinjau" +"Present this flow" = "Presentasikan alur ini" +"Presentation" = "Presentasi" +"Presentation slides" = "Slide presentasi" +"Presenting: comments" = "Presentasi: komentar" +"Presenting: cycle the size options" = "Presentasi: beralih opsi ukuran" +"Presenting: full screen" = "Presentasi: layar penuh" +"Presenting: next frame" = "Presentasi: frame berikutnya" +"Presenting: previous frame" = "Presentasi: frame sebelumnya" +"Presenting: restart" = "Presentasi: mulai ulang" +"Preserve scroll position" = "Pertahankan posisi scroll" +"Presets" = "Preset" +"Press a key…" = "Tekan tombol…" +"Playground" = "Playground" +"Preview" = "Pratinjau" +"Preview this flow" = "Pratinjau alur ini" +"Preview this frame" = "Pratinjau frame ini" +"Previous ready for dev" = "Versi siap dev sebelumnya" +"Progressive" = "Progresif" +"Properties" = "Properti" +"Prototype" = "Prototype" +"Prototype preview" = "Pratinjau prototype" +"Prototype settings" = "Pengaturan prototype" +"Publish" = "Publikasikan" +"Publish changes" = "Publikasikan perubahan" +"Publish to another server…" = "Publikasikan ke server lain…" +"Publish to server" = "Publikasikan ke server" +"Published version {v}" = "Versi {v} dipublikasikan" +"Published version {v} to the server" = "Versi {v} dipublikasikan ke server" +"Published version {v}: save the file so other files can use it" = "Versi {v} dipublikasikan: simpan file agar file lain dapat menggunakannya" +"Published · {people}" = "Dipublikasikan · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publikasi ke server memerlukan aplikasi desktop; di browser Anda dapat bergabung ke file server" +"Publishing with the server's key." = "Memublikasikan dengan kunci server." +"Publishing…" = "Memublikasikan…" +"Push" = "Dorong" +"Push changes to main component" = "Terapkan perubahan ke main component" +"Put text on path" = "Tempatkan teks di path" +"R" = "R" +"Radial" = "Radial" +"Radius" = "Radius" +"Ratio" = "Rasio" +"Reading the library…" = "Membaca pustaka…" +"Ready for dev" = "Siap untuk dev" +"Real name" = "Nama asli" +"Recent" = "Terbaru" +"Recent files" = "File terbaru" +"Recents" = "Terbaru" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { other = "Memulihkan {n} file yang belum disimpan setelah aplikasi keluar tiba-tiba: simpan agar tidak hilang" } +"Rectangle" = "Persegi panjang" +"Rectangles" = "Persegi panjang" +"Red" = "Merah" +"Reduce motion" = "Kurangi gerakan" +"Refraction" = "Refraksi" +"Regular expression" = "Ekspresi reguler" +"Remove" = "Hapus" +"Remove all" = "Hapus semua" +"Remove annotation" = "Hapus anotasi" +"Remove auto layout" = "Hapus auto layout" +"Remove dev resource" = "Hapus sumber daya dev" +"Remove export setting" = "Hapus pengaturan ekspor" +"Remove from recents" = "Hapus dari terbaru" +"Remove from server" = "Hapus dari server" +"Remove from server…" = "Hapus dari server…" +"Remove guide" = "Hapus garis bantu" +"Remove interaction" = "Hapus interaksi" +"Remove library from this file" = "Hapus pustaka dari file ini" +"Remove min and max" = "Hapus min dan maks" +"Remove min and max height" = "Hapus tinggi min dan maks" +"Remove min and max width" = "Hapus lebar min dan maks" +"Remove regions" = "Hapus area" +"Remove slot" = "Hapus slot" +"Remove starting point" = "Hapus titik awal" +"Remove stop" = "Hapus stop" +"Remove text from path" = "Lepaskan teks dari path" +"Remove the else and its actions" = "Hapus else beserta aksinya" +"Remove this condition and its actions" = "Hapus kondisi ini beserta aksinya" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Hapus file ini dari server? File tidak dapat dibagikan atau dibuka dari sana lagi. Salinan Anda tetap terbuka di sini." +"Remove {name} from {server}?" = "Hapus {name} dari {server}?" +"Removed" = "Dihapus" +"Removed from the server" = "Dihapus dari server" +"Removed since it was marked" = "Dihapus sejak ditandai" +"Rename" = "Ganti nama" +"Rename duplicated layers" = "Ganti nama layer duplikat" +"Rename folder" = "Ganti nama folder" +"Rename group" = "Ganti nama grup" +"Rename layers" = "Ganti nama layer" +"Rename mode" = "Ganti nama mode" +"Rename on the server" = "Ganti nama di server" +"Rename on the server…" = "Ganti nama di server…" +"Rename page" = "Ganti nama halaman" +"Rename to" = "Ganti nama menjadi" +"Replace all" = "Ganti semua" +"Replace mixed export settings" = "Ganti pengaturan ekspor campuran" +"Replace with" = "Ganti dengan" +"Replace with a new interaction" = "Ganti dengan interaksi baru" +"Reply" = "Balas" +"Report" = "Laporkan" +"Report this file…" = "Laporkan file ini…" +"Reset" = "Reset" +"Reset adjustments" = "Reset penyesuaian" +"Reset all changes" = "Reset semua perubahan" +"Reset all overrides on this layer" = "Reset semua override pada layer ini" +"Reset overrides" = "Reset override" +"Reset slot" = "Reset slot" +"Reset this override" = "Reset override ini" +"Reset to style" = "Reset ke gaya" +"Reset to the inherited value" = "Reset ke nilai turunan" +"Resize by the nudge amount" = "Ubah ukuran sebesar jarak geser" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ubah ukuran frame ke layar perangkat (atau jendela tanpa perangkat): batasan dan auto layout-nya menyesuaikan" +"Resize to fit" = "Ubah ukuran agar pas" +"Resolve" = "Selesaikan" +"Resolve change" = "Selesaikan perubahan" +"Resolved" = "Terselesaikan" +"Resources" = "Sumber daya" +"Responsive" = "Responsif" +"Restart" = "Mulai ulang" +"Restore" = "Pulihkan" +"Restore main component" = "Pulihkan main component" +"Restore this version" = "Pulihkan versi ini" +"Restored “{name}”" = "“{name}” dipulihkan" +"Reverse the order" = "Balik urutan" +"Reversed triangle" = "Segitiga terbalik" +"Review and merge" = "Tinjau dan merge" +"Ribbon" = "Pita" +"Right" = "Kanan" +"Right side" = "Sisi kanan" +"Right side width" = "Lebar sisi kanan" +"Rotate 90°" = "Putar 90°" +"Rotate 90° right" = "Putar 90° ke kanan" +"Rotate to landscape" = "Putar ke lanskap" +"Rotate to portrait" = "Putar ke potret" +"Rotation" = "Rotasi" +"Rotation jitter" = "Variasi rotasi" +"Round" = "Bulat" +"Rounded" = "Membulat" +"Row" = "Baris" +"Row gap" = "Jarak baris" +"Row sizes" = "Ukuran baris" +"Row span" = "Rentang baris" +"Rows" = "Baris" +"Rows {count}" = "Baris {count}" +"Rulers" = "Penggaris" +"Run" = "Jalankan" +"Run {label}" = "Jalankan {label}" +"Search templates" = "Cari templat" +"See all templates" = "Lihat semua templat" +"Select layers and they space themselves as their content changes" = "Pilih layer dan jaraknya akan menyesuaikan saat kontennya berubah" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Sama dengan layer" +"Saturation" = "Saturasi" +"Save" = "Simpan" +"Save as" = "Simpan sebagai" +"Save as preset…" = "Simpan sebagai preset…" +"Save version {key}" = "Simpan versi {key}" +"Saved" = "Tersimpan" +"Saved {name}" = "{name} disimpan" +"Say something" = "Tulis sesuatu" +"Scale" = "Skala" +"Scatter" = "Hamburan" +"Scoping: where pickers offer it" = "Cakupan: tempat pemilih menampilkannya" +"Screen" = "Screen" +"Scroll behavior" = "Perilaku gulir" +"Scroll to" = "Gulir ke" +"Scroll to layer" = "Gulir ke layer" +"Scroll with parent" = "Gulir bersama induk" +"Search plugins" = "Cari plugin" +"Search" = "Cari" +"Search actions, layers and tools" = "Cari tindakan, layer, dan alat" +"Search assets" = "Cari aset" +"Search comments" = "Cari komentar" +"Search fonts" = "Cari font" +"Search layers" = "Cari layer" +"Search shortcuts" = "Cari pintasan" +"Section" = "Section" +"Section color" = "Warna section" +"Sections" = "Section" +"Select a frame and add a starting point to present it as a flow." = "Pilih frame dan tambahkan titik awal untuk menyajikannya sebagai alur." +"Select a frame to preview it" = "Pilih frame untuk mempratinjaunya" +"Select a layer to see its properties and code" = "Pilih layer untuk melihat properti dan kodenya" +"Select all" = "Pilih semua" +"Select all with same" = "Pilih semua yang sama" +"Select layer" = "Pilih layer" +"Select layers to apply the style to" = "Pilih layer untuk menerapkan gaya" +"Select overlapping shapes" = "Pilih bentuk yang tumpang tindih" +"Select points to edit their position, radius and mirroring" = "Pilih titik untuk mengedit posisi, radius, dan pencerminannya" +"Select slot" = "Pilih slot" +"Selection" = "Pilihan" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Kotak pilihan, {w} x {h} di {x}, {y}: panah untuk memindahkan, Shift+panah atau {cmd}+panah untuk mengubah ukuran, Enter untuk memilih, Escape untuk membatalkan" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Kotak pilihan: panah untuk memindahkan, Shift+panah atau {cmd}+panah untuk mengubah ukuran, Enter untuk memilih, Escape untuk membatalkan" +"Send backward" = "Bawa mundur" +"Send report" = "Kirim laporan" +"Send to back" = "Bawa ke paling belakang" +"Sep" = "Sep" +"Server address (ws://host:47900/)" = "Alamat server (ws://host:47900/)" +"Server address" = "Alamat server" +"Server for online sharing" = "Server untuk berbagi online" +"Set as default" = "Jadikan default" +"Set variable" = "Atur variabel" +"Set variable mode" = "Atur mode variabel" +"Set variable {name}" = "Atur variabel {name}" +"Set {name} to {value}" = "Atur {name} ke {value}" +"Shadows" = "Bayangan" +"Shape" = "Bentuk" +"Shape builder" = "Shape builder" +"Shape tools" = "Alat bentuk" +"Share" = "Bagikan" +"Share this file" = "Bagikan file ini" +"Share with a code" = "Bagikan dengan kode" +"Share: live session, export" = "Bagikan: sesi langsung, ekspor" +"Shared" = "Dibagikan" +"Shared: copy its code from the file's menu" = "Dibagikan: salin kodenya dari menu file" +"Sharing a file needs the desktop app; here you can join one." = "Berbagi file memerlukan aplikasi desktop; di sini Anda hanya dapat bergabung." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Berbagi di jaringan lokal (berbagi online nonaktif: tidak ada server yang diatur di Preferensi)." +"Sharing on your network · {people}" = "Berbagi di jaringan Anda · {people}" +"Sharing online · {people}" = "Berbagi online · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Berbagi dihentikan: file tetap di server, hanya dapat dibuka oleh Anda" +"Show" = "Tampilkan" +"Show all templates" = "Tampilkan semua templat" +"Show as grid" = "Tampilkan sebagai grid" +"Show as list" = "Tampilkan sebagai daftar" +"Show autosaves" = "Tampilkan simpanan otomatis" +"Show behind transparent areas" = "Tampilkan di balik area transparan" +"Show comments (Shift+C)" = "Tampilkan komentar (Shift+C)" +"Show device frame" = "Tampilkan frame perangkat" +"Show dimensions on objects" = "Tampilkan dimensi pada objek" +"Show hotspot hints" = "Tampilkan petunjuk hotspot" +"Show in Explorer" = "Tampilkan di Explorer" +"Show in Finder" = "Tampilkan di Finder" +"Show in folder" = "Tampilkan di folder" +"Show only this on the canvas" = "Tampilkan hanya ini di kanvas" +"Show open" = "Tampilkan yang terbuka" +"Show section contents" = "Tampilkan isi section" +"Show this layer" = "Tampilkan layer ini" +"Show usage" = "Tampilkan penggunaan" +"Show/Hide" = "Tampilkan/Sembunyikan" +"Show/Hide UI" = "Tampilkan/Sembunyikan UI" +"Side by side" = "Berdampingan" +"Sidebar" = "Bilah samping" +"Simplify stroke" = "Sederhanakan stroke" +"Size" = "Ukuran" +"Size jitter" = "Variasi ukuran" +"Skip ink" = "Lewati tinta" +"Slashed zero" = "Nol bergaris miring" +"Slice" = "Slice" +"Slices" = "Slice" +"Slide in" = "Geser masuk" +"Slide out" = "Geser keluar" +"Slot" = "Slot" +"Slot content" = "Konten slot" +"Small capitals" = "Huruf kapital kecil" +"Small caps" = "Kapital kecil" +"Smart animate" = "Smart animate" +"Snap to pixel grid" = "Snap ke grid piksel" +"Social and slides" = "Media sosial dan slide" +"Social media" = "Media sosial" +"Social post sizes" = "Ukuran postingan media sosial" +"Soft light" = "Soft light" +"Solid" = "Solid" +"Some variants have the same property values: give each one a unique combination" = "Beberapa variant memiliki nilai properti yang sama: berikan kombinasi unik untuk masing-masing" +"Sort" = "Urutkan" +"Sort by" = "Urutkan menurut" +"Sort by date" = "Urutkan menurut tanggal" +"Sort by page" = "Urutkan menurut halaman" +"Sort by {column}" = "Urutkan menurut {column}" +"Space around" = "Space around" +"Space between" = "Space between" +"Space drag" = "Seret dengan Space" +"Space evenly" = "Space evenly" +"Space+drag" = "Space+seret" +"Spacing" = "Spasi" +"Spam, stolen work, abuse…" = "Spam, karya curian, penyalahgunaan…" +"Splay" = "Bukaan" +"Spotlight me" = "Sorot saya" +"Spread" = "Sebaran" +"Square" = "Persegi" +"Standard" = "Standar" +"Standard ligatures" = "Ligatur standar" +"Star" = "Bintang" +"Stars" = "Bintang" +"Start" = "Mulai" +"Start ascending from / stop descending at" = "Mulai naik dari / berhenti turun di" +"Start from a blank canvas" = "Mulai dari kanvas kosong" +"Start from a template" = "Mulai dari templat" +"Start on path" = "Mulai pada jalur" +"Start point" = "Titik awal" +"Start sharing" = "Mulai berbagi" +"Start sharing with the Share button, then pass on the code it gives" = "Mulai berbagi dengan tombol Bagikan, lalu teruskan kode yang diberikannya" +"Starting to share…" = "Memulai berbagi…" +"Status" = "Status" +"Sticky" = "Menempel" +"Stop" = "Hentikan" +"Stop following {name}" = "Berhenti mengikuti {name}" +"Stop session" = "Hentikan sesi" +"Stop sharing" = "Berhenti berbagi" +"Stop spotlighting" = "Hentikan sorotan" +"Stop {n} {hex}" = "Titik {n} {hex}" +"Stretch" = "Regangkan" +"Strikethrough" = "Coret" +"String" = "String" +"Stroke" = "Stroke" +"Stroke style" = "Gaya stroke" +"Stroke weight" = "Ketebalan stroke" +"Strokes" = "Stroke" +"Style" = "Gaya" +"Styles" = "Gaya" +"Stylistic set 1" = "Set gaya 1" +"Stylistic set 2" = "Set gaya 2" +"Stylistic set 3" = "Set gaya 3" +"Stylistic set 4" = "Set gaya 4" +"Stylistic set 5" = "Set gaya 5" +"Subtract" = "Kurangi" +"Subtracts" = "Pengurangan" +"Suffix" = "Sufiks" +"Suspend snapping while dragging" = "Tangguhkan snapping saat menyeret" +"Swap instance" = "Tukar instance" +"Swap library: use another library's assets of the same names" = "Tukar pustaka: gunakan aset bernama sama dari pustaka lain" +"Swap orientation" = "Tukar orientasi" +"Swap overlay" = "Tukar overlay" +"Swap to {name}" = "Tukar ke {name}" +"Swap width and height" = "Tukar lebar dan tinggi" +"Swapped instance" = "Instance ditukar" +"Swapped {n} assets" = { other = "{n} aset ditukar" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { other = "{n} aset ditukar; {missed} tidak memiliki padanan dan masih menggunakan pustaka lama" } +"Sweep" = "Sapuan" +"System" = "Sistem" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Tabel" +"Tablet" = "Tablet" +"Tabular numbers" = "Angka tabular" +"Take the values from the first selected layer" = "Ambil nilai dari layer pertama yang dipilih" +"Taper" = "Runcing" +"Taper both ends" = "Runcingkan kedua ujung" +"Tell the server's operator what is wrong with it" = "Beri tahu operator server apa masalahnya" +"Temperature" = "Suhu" +"Template" = "Templat" +"Templates" = "Templat" +"Terms" = "Ketentuan" +"Text" = "Teks" +"Text align" = "Perataan teks" +"Text content" = "Konten teks" +"Text style" = "Gaya teks" +"Text styles" = "Gaya teks" +"Texts" = "Teks" +"Texture" = "Tekstur" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Kanvas mempratinjau warna dalam sRGB; file PNG, JPG, dan WebP yang diekspor ditandai Display P3." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "File dihapus dari server: tidak ada yang dapat membuka atau bergabung ke file itu dari sana lagi, dan tindakan ini tidak dapat diurungkan. Salinan yang sedang terbuka tetap ada pada pemiliknya." +"The file now holds another library" = "File kini berisi pustaka lain" +"The file now holds another library (\"{name}\")" = "File kini berisi pustaka lain (\"{name}\")" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "File yang Anda bagikan atau publikasikan secara online dari komputer ini. File tersebut tetap di servernya hingga Anda menghapusnya." +"The link carries the session's secret: share it only with people you invite." = "Link ini memuat rahasia sesi: bagikan hanya kepada orang yang Anda undang." +"The name in your online files and on the server. The document inside keeps its own name." = "Nama di file online Anda dan di server. Dokumen di dalamnya tetap memakai namanya sendiri." +"The selection uses several text styles: click to apply one" = "Pilihan menggunakan beberapa gaya teks: klik untuk menerapkan salah satunya" +"The server deletes this file if nobody opens it before {date}." = "Server akan menghapus file ini jika tidak ada yang membukanya sebelum {date}." +"The whole file" = "Seluruh file" +"Theme" = "Tema" +"There's no destination for that action" = "Tidak ada tujuan untuk tindakan itu" +"Thickness" = "Ketebalan" +"This branch can't be reviewed: {e}" = "Branch ini tidak dapat ditinjau: {e}" +"This branch was merged or archived." = "Branch ini telah di-merge atau diarsipkan." +"This computer only" = "Hanya komputer ini" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Komputer ini mengingat token pemilik, sehingga file dapat dikelola dari daftar Online di layar beranda. Salin token untuk mengelola file dari tempat lain: token ini adalah kata sandi untuk file ini dan tidak akan ditampilkan lagi." +"This condition is edited as an expression" = "Kondisi ini diedit sebagai ekspresi" +"This file" = "File ini" +"This file has unsaved changes" = "File ini memiliki perubahan yang belum disimpan" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "File ini ada di {server}, hanya dapat dibuka oleh Anda. Bagikan untuk mengizinkan orang lain masuk dengan kode baru." +"This frame is new on the branch." = "Frame ini baru di branch." +"This frame isn't on the branch." = "Frame ini tidak ada di branch." +"This server is open to the public: no key needed." = "Server ini terbuka untuk publik: tidak perlu kunci." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Server ini memerlukan kuncinya: tambahkan ?key=… ke alamat (`figcraft-server key` menampilkannya di mesin server)." +"This server takes files only with its key: see Advanced." = "Server ini hanya menerima file dengan kuncinya: lihat Lanjutan." +"Tidy up" = "Rapikan" +"Tile" = "Ubin" +"Tile type" = "Jenis ubin" +"Tint" = "Rona" +"Title" = "Judul" +"Title case" = "Huruf Awal Kapital" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Untuk membagikan pustaka ini kepada tim Anda, publikasikan file ke server (Bagikan ▸ Publikasikan ke server…), lalu publikasikan pustaka di sana." +"Toggle" = "Alihkan" +"Toggle light/dark theme" = "Alihkan tema terang/gelap" +"Toggle visibility" = "Alihkan visibilitas" +"Tools" = "Alat" +"Top" = "Atas" +"Top & bottom" = "Atas & bawah" +"Top left radius" = "Radius kiri atas" +"Top right radius" = "Radius kanan atas" +"Transform" = "Transformasi" +"Triangle arrow" = "Panah segitiga" +"Trigger" = "Pemicu" +"True" = "Benar" +"Truncate text" = "Potong teks" +"Truncate with an ellipsis" = "Potong dengan elipsis" +"Try again" = "Coba lagi" +"Try another search or filter." = "Coba pencarian atau filter lain." +"Try another word, or look at every kind." = "Coba kata lain, atau lihat semua jenis." +"Turn a layer into a component, reuse it, and change every copy at once" = "Ubah layer menjadi component, gunakan ulang, dan ubah semua salinannya sekaligus" +"Two digits" = "Dua digit" +"Type" = "Tipe" +"Type settings" = "Pengaturan teks" +"Type the join code you were given, or paste an invite link." = "Ketik kode gabung yang Anda terima, atau tempel link undangan." +"Typography" = "Tipografi" +"Underline" = "Garis bawah" +"Undo takes back only your own changes; what others changed since stays." = "Urungkan hanya membatalkan perubahan Anda sendiri; perubahan orang lain sejak itu tetap ada." +"Ungroup" = "Pisahkan grup" +"Uniform" = "Seragam" +"Union" = "Gabungkan" +"Unions" = "Gabungan" +"Unknown — treated as sRGB" = "Tidak diketahui — dianggap sRGB" +"Unread" = "Belum dibaca" +"Unresolve" = "Buka kembali" +"Unsaved changes" = "Perubahan belum disimpan" +"Unsaved changes in {names}. Close the window and lose them?" = "Perubahan belum disimpan di {names}. Tutup jendela dan buang perubahan?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Perubahan belum disimpan: simpan dulu ({save}), atau tutup tab dengan {alt}-klik untuk membuangnya" +"Unsaved changes: save first, or close again with {key} to discard" = "Perubahan belum disimpan: simpan dulu, atau tutup lagi dengan {key} untuk membuangnya" +"Up" = "Atas" +"Update plugin" = "Perbarui plugin" +"Update" = "Perbarui" +"Update from main" = "Perbarui dari main" +"Update from selection" = "Perbarui dari pilihan" +"Updated {components} and {styles}" = "{components} dan {styles} diperbarui" +"Updated {name}" = "{name} diperbarui" +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Mengunggah file ini ke server FigCraft dan menjaga tab ini tetap sinkron dengannya. Anda mendapat link untuk mengedit dan link untuk melihat." +"Uppercase" = "Huruf kapital" +"Use a fixed value" = "Gunakan nilai tetap" +"Use an expression, e.g. count + 1" = "Gunakan ekspresi, mis. count + 1" +"Use as mask" = "Gunakan sebagai mask" +"Use auto layout" = "Gunakan auto layout" +"Use number keys for opacity" = "Gunakan tombol angka untuk opasitas" +"Use the layer's fill" = "Gunakan fill layer" +"Used in {files} · {instances}" = "Digunakan di {files} · {instances}" +"Username" = "Nama pengguna" +"Value" = "Nilai" +"Uses variables: each layer shows them in its own mode" = "Menggunakan variabel: tiap layer menampilkannya dalam mode masing-masing" +"Variable" = "Variabel" +"Variable collections" = "Koleksi variabel" +"Variable modes" = "Mode variabel" +"Variable width" = "Lebar variabel" +"Variables" = "Variabel" +"Variables JSON…" = "JSON variabel…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Variabel menyimpan nilai yang dapat digunakan ulang (warna, angka, string, boolean) dengan mode seperti Terang dan Gelap." +"Variant" = "Variant" +"Vector" = "Vector" +"Vectors" = "Vector" +"Version" = "Versi" +"Version history" = "Riwayat versi" +"Version {version}" = "Versi {version}" +"Version {v} · {components} · {styles} · {variables}" = "Versi {v} · {components} · {styles} · {variables}" +"Vertical" = "Vertikal" +"Vertical align" = "Perataan vertikal" +"Vertical hex" = "Heksagon vertikal" +"Vertical padding" = "Padding vertikal" +"Vertical spacing" = "Jarak vertikal" +"Vertical trim" = "Pangkas vertikal" +"View" = "Tampilan" +"View link" = "Link lihat" +"View link copied" = "Link lihat disalin" +"View only" = "Hanya lihat" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Hanya lihat: Anda melihat setiap perubahan saat terjadi tetapi tidak dapat mengedit. Keluar untuk menyimpan salinan yang dapat diedit." +"Viewing “{name}”" = "Melihat “{name}”" +"Violet" = "Ungu" +"Visibility" = "Visibilitas" +"Visible" = "Terlihat" +"Vivid" = "Cerah" +"Warm" = "Hangat" +"Watch" = "Jam tangan" +"Wavy" = "Bergelombang" +"Web" = "Web" +"Websites" = "Situs web" +"WebSocket URL" = "URL WebSocket" +"WebSocket URL copied" = "URL WebSocket disalin" +"Wedge" = "Baji" +"Weight" = "Ketebalan" +"What you try here changes this preview and the code below, not the file" = "Yang Anda coba di sini mengubah pratinjau ini dan kode di bawah, bukan file-nya" +"What changed since it was marked ready for dev" = "Yang berubah sejak ditandai siap untuk dev" +"What is wrong with this file? The server's operator gets your report." = "Apa yang salah dengan file ini? Operator server akan menerima laporan Anda." +"What it's for" = "Kegunaannya" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Asal perubahan ini tidak diketahui: perubahan terjadi sebelum FigCraft menyimpan salinan frame siap dev. Selesaikan perubahan (atau tandai siap lagi) agar dapat dibandingkan mulai sekarang." +"While hovering" = "Saat hover" +"While pressing" = "Saat ditekan" +"White" = "Putih" +"Who runs it, its terms and how long it keeps files" = "Siapa pengelolanya, ketentuannya, dan berapa lama file disimpan" +"Why it changed" = "Alasan perubahan" +"Widgets" = "Widget" +"Width" = "Lebar" +"Width point" = "Titik lebar" +"Width profile" = "Profil lebar" +"Wireframe kit" = "Kit wireframe" +"With the layers' export settings" = "Dengan pengaturan ekspor layer" +"Wrap" = "Wrap" +"Wrap in new section" = "Bungkus dalam section baru" +"Yellow" = "Kuning" +"Yesterday" = "Kemarin" +"You" = "Anda" +"You'll get the shared file as a new tab and edit it together." = "Anda akan mendapat file bersama sebagai tab baru dan mengeditnya bersama." +"You're spotlighting: everyone follows your view" = "Anda sedang spotlight: semua orang mengikuti tampilan Anda" +"Your copy of the file stays open" = "Salinan file Anda tetap terbuka" +"Your copy stays open; the file stays on the server" = "Salinan Anda tetap terbuka; file tetap ada di server" +"Your name" = "Nama Anda" +"Your name in comments" = "Nama Anda di komentar" +"Your system asks for reduced motion: animations stay off." = "Sistem Anda meminta gerakan dikurangi: animasi tetap nonaktif." +"Z" = "Z" +"Z to A" = "Z ke A" +"Zoom" = "Zoom" +"Zoom around the pointer" = "Zoom berpusat di penunjuk" +"Zoom device to fill window" = "Zoom perangkat agar memenuhi jendela" +"Zoom in" = "Perbesar" +"Zoom out" = "Perkecil" +"Zoom to 100%" = "Zoom ke 100%" +"Zoom to 200%" = "Zoom ke 200%" +"Zoom to 50%" = "Zoom ke 50%" +"Zoom to fit" = "Zoom agar pas" +"Zoom to selection" = "Zoom ke pilihan" +"Zoom tool: click to zoom in, drag an area" = "Alat zoom: klik untuk memperbesar, seret area" +"Zoom tool: zoom out" = "Alat zoom: perkecil" +"and {n} more" = { other = "dan {n} lainnya" } +"bleed marks" = "tanda bleed" +"by {author}" = "oleh {author}" +"call any command its access allows" = "memanggil perintah apa pun yang diizinkan aksesnya" +"call only: {commands}" = "hanya memanggil: {commands}" +"can edit the document" = "dapat mengedit dokumen" +"crop marks" = "tanda potong" +"development: {path}" = "pengembangan: {path}" +"e.g. count + 1" = "mis. count + 1" +"e.g. count >= 3 and not done" = "mis. count >= 3 and not done" +"figcraft:// links from a browser, chat or email open this app and join the session." = "Link figcraft:// dari browser, chat, atau email membuka aplikasi ini dan bergabung ke sesi." +"from {server}" = "dari {server}" +"hidden" = "tersembunyi" +"iOS" = "iOS" +"just now" = "baru saja" +"locked" = "terkunci" +"no bleed" = "tanpa bleed" +"nobody else here yet" = "belum ada orang lain di sini" +"now" = "sekarang" +"or" = "atau" +"put a widget on the canvas" = "menaruh widget di kanvas" +"read and edit the document" = "membaca dan mengedit dokumen" +"read the document" = "membaca dokumen" +"reads the document" = "membaca dokumen" +"the plugin" = "plugin" +"this session only" = "hanya sesi ini" +"yesterday" = "kemarin" +"{instances} in {files}" = "{instances} di {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: terjemahan draf, menunggu tinjauan penutur asli." +"{length} bleed" = "bleed {length}" +"{month} {day}, {year}" = "{day} {month} {year}" +"{name} cleared from the list" = "{name} dihapus dari daftar" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} dilupakan di komputer ini; file tetap ada di {server}" +"{name} is on the server." = "{name} ada di server." +"{name} is spotlighting" = "{name} sedang spotlight" +"{name}\nClick to apply to the selection" = "{name}\nKlik untuk menerapkan ke pilihan" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nSeret ke kanvas, atau klik untuk menaruh di tengah tampilan.\nTahan Alt (Option) saat melepas di atas instance untuk menukarnya." +"{name} (from {library})" = "{name} (dari {library})" +"{name} (you)" = "{name} (Anda)" +"{name} ({layers} layers)" = "{name} ({layers} layer)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, default {default}. Klik dua kali slider untuk kembali ke nilai gaya; Escape saat menyeret untuk membatalkan." +"{name} from {path}" = "{name} dari {path}" +"{name} was removed from the server" = "{name} dihapus dari server" +"{name} · deleted on main" = "{name} · dihapus di main" +"{name} · deleted on the branch" = "{name} · dihapus di branch" +"{name} · not published" = "{name} · belum dipublikasikan" +"{name} · on a server" = "{name} · di server" +"{name} · published version {v}" = "{name} · versi terpublikasi {v}" +"{name} · version {v} on the server" = "{name} · versi {v} di server" +"{name}, in a live session" = "{name}, dalam sesi langsung" +"{name}, online, open only to you" = "{name}, online, hanya terbuka untuk Anda" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} x {h}, dipilih" +"{n} added" = { other = "{n} ditambahkan" } +"{n} autosave versions" = { other = "{n} versi simpan otomatis" } +"{n} changed" = { other = "{n} diubah" } +"{n} columns" = { other = "{n} kolom" } +"{n} components" = { other = "{n} component" } +"{n} components and styles have newer versions" = { other = "{n} component dan gaya memiliki versi lebih baru" } +"{n} conflicts to resolve" = { other = "{n} konflik untuk diselesaikan" } +"{n} days ago" = { other = "{n} hari lalu" } +"{n} files" = { other = "{n} file" } +"{n} hours ago" = { other = "{n} jam lalu" } +"{n} instances" = { other = "{n} instance" } +"{n} layers" = { other = "{n} layer" } +"{n} layers as one file" = { other = "{n} layer sebagai satu file" } +"{n} layers selected" = { other = "{n} layer dipilih" } +"{n} layers · {parts}" = { other = "{n} layer · {parts}" } +"{n} matches" = { other = "{n} kecocokan" } +"{n} minutes ago" = { other = "{n} menit lalu" } +"{n} months ago" = { other = "{n} bulan lalu" } +"{n} of {total}" = "{n} dari {total}" +"{n} people joined" = { other = "{n} orang bergabung" } +"{n} preferred components" = { other = "{n} component pilihan" } +"{n} ready" = { other = "{n} siap" } +"{n} removed" = { other = "{n} dihapus" } +"{n} replies" = { other = "{n} balasan" } +"{n} rows" = { other = "{n} baris" } +"{n} styles" = { other = "{n} gaya" } +"{n} variables" = { other = "{n} variabel" } +"{n} weeks ago" = { other = "{n} minggu lalu" } +"{n} years ago" = { other = "{n} tahun lalu" } +"{n}d" = "{n} hr" +"{n}h" = "{n} j" +"{n}m" = "{n} mnt" +"{n}w" = "{n} mgg" +"{page}, nothing selected" = "{page}, tidak ada yang dipilih" +"{profile} (current)" = "{profile} (saat ini)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} adalah nilainya; {property ? \"a\" : \"b\"} memilih string. {…} lainnya tetap seperti tertulis." +"{server} can't say what became of your files (it is older than this app)." = "{server} tidak dapat memberi tahu nasib file Anda (versinya lebih lama dari aplikasi ini)." +"{server} · No longer on the server" = "{server} · Tidak ada lagi di server" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Dibuka {date}" +"{tool} hold {key}" = "{tool} tahan {key}" +"{value} isn't a colour" = "{value} bukan warna" +"{when} · edited" = "{when} · diedit" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Spasi atau N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ panah" +"⌘ click" = "⌘ klik" +"⌘ scroll" = "⌘ gulir" +"⌥ drag" = "⌥ seret" + +[commands] +"align.bottom" = "Ratakan bawah" +"align.hcenter" = "Ratakan tengah horizontal" +"align.left" = "Rata kiri" +"align.right" = "Rata kanan" +"align.top" = "Ratakan atas" +"align.vcenter" = "Ratakan tengah vertikal" +"annotation.add" = "Tambah anotasi" +"annotation.addCategory" = "Tambah kategori anotasi" +"annotation.categories" = "Kategori anotasi" +"annotation.edit" = "Edit anotasi" +"annotation.editCategory" = "Edit kategori anotasi" +"annotation.list" = "Daftar anotasi" +"annotation.remove" = "Hapus anotasi" +"annotation.removeCategory" = "Hapus kategori anotasi" +"app.actions" = "Tindakan" +"app.preferences" = "Preferensi" +"arrange.back" = "Bawa ke paling belakang" +"arrange.backward" = "Bawa mundur" +"arrange.forward" = "Bawa maju" +"arrange.front" = "Bawa ke paling depan" +"arrange.packH" = "Rapatkan horizontal" +"arrange.packV" = "Rapatkan vertikal" +"arrange.roundToPixels" = "Bulatkan ke piksel" +"arrange.tidy" = "Rapikan" +"bool.exclude" = "Kecualikan pilihan" +"bool.flatten" = "Satukan boolean" +"bool.intersect" = "Iriskan pilihan" +"bool.subtract" = "Kurangi pilihan" +"bool.union" = "Gabungkan pilihan" +"branch.archive" = "Arsipkan branch" +"branch.create" = "Buat branch" +"branch.list" = "Daftar branch" +"branch.merge" = "Merge branch" +"branch.open" = "Buka branch" +"branch.review" = "Tinjau branch" +"branch.updateFromMain" = "Perbarui dari main" +"codeConnect.export" = "Ekspor koneksi kode" +"codeConnect.import" = "Impor koneksi kode" +"codeConnect.remove" = "Putuskan dari kode" +"codeConnect.set" = "Hubungkan ke kode" +"collab.cursor" = "Tampilkan kursor di" +"collab.cursorChat" = "Chat kursor" +"collab.diff" = "Tampilkan perubahan sinkronisasi" +"collab.files" = "File online Anda" +"collab.follow" = "Ikuti" +"collab.forgetFile" = "Lupakan file online" +"collab.host" = "Mulai sesi langsung" +"collab.join" = "Gabung sesi langsung" +"collab.leave" = "Keluar dari sesi langsung" +"collab.manageFile" = "Kelola file yang dipublikasikan" +"collab.openFile" = "Buka file online" +"collab.peers" = "Orang di file ini" +"collab.publish" = "Publikasikan ke server" +"collab.recoverFile" = "Pulihkan file online" +"collab.registerLinks" = "Buka link undangan di FigCraft" +"collab.report" = "Laporkan file ke operator server" +"collab.serverInfo" = "Info server" +"collab.share" = "Bagikan dengan kode bergabung" +"collab.spotlight" = "Sorot saya" +"collab.stop" = "Hentikan sesi langsung" +"command.batch" = "Jalankan beberapa perintah" +"comment.add" = "Tambah komentar" +"comment.delete" = "Hapus komentar" +"comment.edit" = "Edit komentar" +"comment.link" = "Salin link ke komentar" +"comment.list" = "Daftar komentar" +"comment.markRead" = "Tandai komentar sudah dibaca" +"comment.move" = "Pindahkan komentar" +"comment.next" = "Komentar berikutnya" +"comment.open" = "Buka komentar" +"comment.people" = "Orang di komentar" +"comment.react" = "Beri reaksi pada komentar" +"comment.reply" = "Balas komentar" +"comment.resolve" = "Selesaikan komentar" +"comment.zoom" = "Zoom ke komentar" +"component.addProperty" = "Tambah properti component" +"component.addVariant" = "Tambah variant" +"component.bindProperty" = "Ikat layer ke properti" +"component.combineVariants" = "Gabungkan sebagai variant" +"component.create" = "Buat component" +"component.createMultiple" = "Buat beberapa component" +"component.deleteProperty" = "Hapus properti component" +"component.detach" = "Lepaskan instance" +"component.editProperty" = "Edit properti component" +"component.exposeInstance" = "Tampilkan instance bersarang" +"component.goToMain" = "Buka main component" +"component.hideWhenPublishing" = "Sembunyikan saat dipublikasikan" +"component.list" = "Daftar component" +"component.makeSlot" = "Jadikan slot" +"component.playground" = "Playground component" +"component.properties" = "Properti component" +"component.pushChanges" = "Terapkan perubahan ke main component" +"component.removeSlot" = "Hapus slot" +"component.resetInstance" = "Reset semua perubahan" +"component.resetOverride" = "Reset override" +"component.restoreMain" = "Pulihkan main component" +"dev.compare" = "Bandingkan perubahan" +"dev.focus" = "Tampilan fokus" +"dev.resolveChange" = "Selesaikan perubahan" +"dev.unfocus" = "Keluar dari tampilan fokus" +"devResource.add" = "Tambah sumber daya dev" +"devResource.edit" = "Edit sumber daya dev" +"devResource.list" = "Daftar sumber daya dev" +"devResource.remove" = "Hapus sumber daya dev" +"distribute.horizontal" = "Sebarkan jarak horizontal" +"distribute.vertical" = "Sebarkan jarak vertikal" +"doc.home" = "Beranda" +"doc.list" = "Daftar dokumen" +"doc.nextTab" = "Tab berikutnya" +"doc.previousTab" = "Tab sebelumnya" +"doc.switch" = "Ganti dokumen" +"edit.copy" = "Salin" +"edit.copyAsCss" = "Salin sebagai CSS" +"edit.copyAsPng" = "Salin sebagai PNG" +"edit.copyAsSvg" = "Salin sebagai SVG" +"edit.copyAsText" = "Salin sebagai teks" +"edit.copyLink" = "Salin link ke pilihan" +"edit.copyProperties" = "Salin properti" +"edit.cut" = "Potong" +"edit.delete" = "Hapus" +"edit.duplicate" = "Duplikat" +"edit.findReplace" = "Cari dan ganti" +"edit.paste" = "Tempel" +"edit.pasteOver" = "Tempel di atas pilihan" +"edit.pasteProperties" = "Tempel properti" +"edit.pasteReplace" = "Tempel dan ganti" +"edit.pickColor" = "Ambil warna" +"edit.redo" = "Ulangi" +"edit.repeat" = "Ulangi perintah terakhir" +"edit.setDefaultProperties" = "Atur properti default" +"edit.undo" = "Urungkan" +"effect.add" = "Tambah efek" +"effect.move" = "Pindahkan efek" +"effect.remove" = "Hapus efek" +"effect.set" = "Atur efek" +"export.add" = "Tambah pengaturan ekspor" +"export.remove" = "Hapus pengaturan ekspor" +"export.update" = "Edit pengaturan ekspor" +"file.close" = "Tutup tab" +"file.colorProfile" = "Profil warna" +"file.duplicate" = "Duplikat file" +"file.export" = "Ekspor" +"file.exportFramesPdf" = "Ekspor frame ke PDF" +"file.exportLayers" = "Ekspor layer" +"file.exportSvg" = "Ekspor pilihan sebagai SVG" +"file.import" = "Impor" +"file.new" = "File desain baru" +"file.newFromTemplate" = "Baru dari templat" +"file.open" = "Buka" +"file.openRecent" = "Buka terbaru" +"file.placeImage" = "Tempatkan gambar" +"file.recover" = "Pulihkan file" +"file.rename" = "Ganti nama file" +"file.revert" = "Kembalikan ke versi tersimpan" +"file.save" = "Simpan" +"file.saveAs" = "Simpan sebagai" +"file.saveVersion" = "Simpan ke riwayat versi" +"file.versionHistory" = "Tampilkan riwayat versi" +"fill.add" = "Tambah fill" +"fill.move" = "Pindahkan fill" +"fill.pattern" = "Fill pola" +"fill.remove" = "Hapus fill" +"fill.set" = "Atur fill" +"frame.create" = "Buat frame" +"frame.presets" = "Daftar preset frame" +"frame.swapOrientation" = "Tukar orientasi" +"grid.add" = "Tambah layout grid" +"grid.move" = "Pindahkan layout grid" +"grid.remove" = "Hapus layout grid" +"guide.add" = "Tambah garis bantu" +"guide.clear" = "Hapus semua garis bantu" +"guide.move" = "Pindahkan garis bantu" +"guide.remove" = "Hapus garis bantu" +"guide.select" = "Pilih garis bantu" +"help.gettingStarted" = "Memulai" +"help.about" = "Tentang FigCraft" +"help.page" = "Halaman bantuan" +"help.shortcuts" = "Pintasan keyboard" +"image.adjust" = "Sesuaikan gambar" +"image.deletePreset" = "Hapus preset gambar" +"image.place" = "Tempatkan gambar" +"image.presets" = "Preset gambar" +"image.savePreset" = "Simpan preset gambar" +"inspect.assets" = "Inspeksi aset" +"inspect.code" = "Kode untuk layer" +"inspect.componentCode" = "Kode component" +"inspect.properties" = "Inspeksi properti" +"inspect.readyForDev" = "Siap untuk dev" +"instance.create" = "Buat instance" +"instance.exposed" = "Instance bersarang yang ditampilkan" +"instance.place" = "Tempatkan instance" +"instance.resetSlot" = "Reset slot" +"instance.setProperties" = "Atur properti instance" +"instance.slotInsert" = "Sisipkan ke slot" +"instance.swap" = "Tukar instance" +"instance.swapAt" = "Tukar instance di titik" +"layers.collapseAll" = "Ciutkan layer" +"layout.add" = "Tambah auto layout" +"layout.remove" = "Hapus auto layout" +"layout.setTrack" = "Atur ukuran track grid" +"layout.suggest" = "Sarankan auto layout" +"libraries.open" = "Pustaka" +"libraries.publish" = "Publikasikan pustaka…" +"library.acceptUpdates" = "Terima pembaruan pustaka" +"library.add" = "Tambah pustaka" +"library.aliasVariable" = "Buat alias variabel pustaka" +"library.applyStyle" = "Terapkan gaya pustaka" +"library.bindVariable" = "Ikat variabel pustaka" +"library.extendCollection" = "Perluas koleksi pustaka" +"library.import" = "Impor component pustaka" +"library.importVariable" = "Impor variabel pustaka" +"library.list" = "Daftar pustaka" +"library.place" = "Tempatkan component pustaka" +"library.publish" = "Publikasikan pustaka" +"library.publishToServer" = "Publikasikan pustaka ke server" +"library.remove" = "Hapus pustaka" +"library.setVariableMode" = "Atur mode variabel pustaka" +"library.swap" = "Tukar pustaka" +"library.swapAt" = "Tukar instance ke component pustaka" +"library.updates" = "Pembaruan pustaka" +"library.usage" = "Penggunaan pustaka" +"measure.distances" = "Ukur jarak" +"measure.padding" = "Ukur padding auto layout" +"node.create" = "Buat layer" +"node.get" = "Ambil layer" +"node.reorder" = "Ubah urutan layer" +"node.reparent" = "Pindahkan layer ke dalam" +"node.set" = "Atur properti" +"object.batchRename" = "Ganti nama massal" +"object.convertToFrame" = "Konversi ke frame" +"object.convertToGroup" = "Konversi ke grup" +"object.flipH" = "Balik horizontal" +"object.flipV" = "Balik vertikal" +"object.frameSelection" = "Buat frame dari pilihan" +"object.group" = "Grupkan pilihan" +"object.hideOthers" = "Sembunyikan layer lain" +"object.mask" = "Gunakan sebagai mask" +"object.moveToPage" = "Pindahkan ke halaman" +"object.opacityKeys" = "Opasitas dari tombol angka" +"object.rasterize" = "Rasterisasi pilihan" +"object.releaseMask" = "Lepaskan mask" +"object.removeFill" = "Hapus fill" +"object.removeFrame" = "Hapus frame" +"object.removeStroke" = "Hapus stroke" +"object.rename" = "Ganti nama" +"object.resizeToFit" = "Ubah ukuran agar pas" +"object.rotate180" = "Putar 180°" +"object.rotateLeft" = "Putar 90° ke kiri" +"object.rotateRight" = "Putar 90° ke kanan" +"object.setThumbnail" = "Jadikan thumbnail" +"object.swapFillStroke" = "Tukar fill dan stroke" +"object.toggleLock" = "Kunci/Buka kunci pilihan" +"object.toggleVisible" = "Tampilkan/Sembunyikan pilihan" +"object.ungroup" = "Pisahkan grup pilihan" +"object.wrapInSection" = "Bungkus dalam section baru" +"page.add" = "Tambah halaman" +"page.delete" = "Hapus halaman" +"page.duplicate" = "Duplikat halaman" +"page.list" = "Daftar halaman" +"page.move" = "Pindahkan halaman" +"page.rename" = "Ganti nama halaman" +"page.select" = "Buka halaman" +"page.setBackground" = "Latar belakang halaman" +"paint.documentColors" = "Warna dokumen" +"plugin.close" = "Tutup panel plugin" +"plugin.directory.install" = "Instal plugin dari direktori" +"plugin.directory.list" = "Jelajahi direktori plugin" +"plugin.directory.publish" = "Publikasikan plugin ke direktori" +"plugin.directory.unpublish" = "Batalkan publikasi plugin dari direktori" +"plugin.event" = "Gunakan kontrol panel plugin" +"plugin.import" = "Impor plugin dari manifest…" +"plugin.list" = "Daftar plugin" +"plugin.remove" = "Hapus plugin" +"plugin.run" = "Jalankan plugin" +"plugin.ui" = "Panel plugin" +"plugins.manage" = "Kelola plugin…" +"plugins.runLast" = "Jalankan plugin terakhir" +"pref.highlightHover" = "Sorot layer saat hover" +"pref.keepTool" = "Tetap pilih alat setelah dipakai" +"pref.language" = "Bahasa antarmuka" +"pref.nudge" = "Jarak geser" +"pref.numberKeysOpacity" = "Gunakan tombol angka untuk opasitas" +"pref.publishServer" = "Server untuk berbagi online" +"pref.reduceMotion" = "Kurangi gerakan" +"pref.renameDuplicates" = "Ganti nama layer duplikat" +"pref.showDimensions" = "Tampilkan dimensi pada objek" +"pref.userName" = "Nama Anda di komentar" +"proto.action.add" = "Tambah aksi prototype" +"proto.action.move" = "Ubah urutan aksi prototype" +"proto.action.remove" = "Hapus aksi prototype" +"proto.action.set" = "Edit aksi prototype" +"proto.closePreview" = "Tutup pratinjau prototype" +"proto.conditional.addBlock" = "Tambah else if / else" +"proto.conditional.removeBlock" = "Hapus if / else if / else" +"proto.conditional.setCondition" = "Edit kondisi" +"proto.evaluate" = "Evaluasi ekspresi prototype" +"proto.flow.add" = "Tambah titik awal flow" +"proto.flow.remove" = "Hapus flow" +"proto.flow.rename" = "Ganti nama flow" +"proto.interaction.add" = "Tambah interaksi" +"proto.interaction.clear" = "Hapus semua interaksi" +"proto.interaction.remove" = "Hapus interaksi" +"proto.interaction.set" = "Edit interaksi" +"proto.present" = "Presentasikan" +"proto.presentOptions" = "Opsi presentasi" +"proto.preview" = "Buka pratinjau prototype" +"proto.settings" = "Pengaturan prototype" +"section.toggleContents" = "Sembunyikan/Tampilkan isi section" +"select.add" = "Tambah ke pilihan" +"select.all" = "Pilih semua" +"select.area" = "Pilih area" +"select.byName" = "Pilih layer berdasarkan nama" +"select.children" = "Pilih anak" +"select.inverse" = "Balik pilihan" +"select.layersAt" = "Layer di satu titik" +"select.matching" = "Pilih layer yang cocok" +"select.next" = "Pilih saudara berikutnya" +"select.none" = "Batalkan pilihan" +"select.parent" = "Pilih induk" +"select.previous" = "Pilih saudara sebelumnya" +"select.remove" = "Hapus dari pilihan" +"select.sameEffect" = "Pilih efek yang sama" +"select.sameFill" = "Pilih fill yang sama" +"select.sameFont" = "Pilih font yang sama" +"select.sameInstance" = "Pilih instance yang sama" +"select.sameProperties" = "Pilih properti yang sama" +"select.sameStroke" = "Pilih stroke yang sama" +"select.sameText" = "Pilih properti teks yang sama" +"select.set" = "Pilih" +"shape.arrow" = "Panah" +"shape.ellipse" = "Elips" +"shape.line" = "Garis" +"shape.polygon" = "Poligon" +"shape.rectangle" = "Persegi panjang" +"shape.star" = "Bintang" +"shapeBuilder.merge" = "Gabungkan area" +"shapeBuilder.mode" = "Mode shape builder" +"shapeBuilder.regions" = "Area shape builder" +"shapeBuilder.remove" = "Hapus area" +"stroke.add" = "Tambah stroke" +"stroke.brush" = "Kuas" +"stroke.move" = "Pindahkan stroke" +"stroke.remove" = "Hapus stroke" +"stroke.set" = "Atur stroke" +"stroke.widthProfile" = "Profil lebar" +"style.apply" = "Terapkan gaya" +"style.bind" = "Ikat variabel di gaya" +"style.create" = "Buat gaya" +"style.delete" = "Hapus gaya" +"style.detach" = "Lepaskan gaya" +"style.duplicate" = "Duplikat gaya" +"style.list" = "Daftar gaya" +"style.move" = "Pindahkan gaya" +"style.renameFolder" = "Ganti nama folder gaya" +"style.update" = "Edit gaya" +"text.alignCenter" = "Rata tengah" +"text.alignJustify" = "Rata kiri-kanan" +"text.alignLeft" = "Rata kiri" +"text.alignRight" = "Rata kanan" +"text.bold" = "Tebal" +"text.bulletList" = "Daftar berpoin" +"text.case" = "Huruf besar/kecil" +"text.caseLower" = "Huruf kecil" +"text.caseOriginal" = "Kapitalisasi asli" +"text.caseSmallCaps" = "Kapital kecil" +"text.caseTitle" = "Huruf Awal Kapital" +"text.caseUpper" = "Huruf kapital" +"text.create" = "Teks" +"text.decoration" = "Dekorasi" +"text.edit.begin" = "Edit teks" +"text.edit.caret" = "Atur kursor" +"text.edit.end" = "Berhenti mengedit teks" +"text.edit.insert" = "Ketik teks" +"text.edit.key" = "Tombol teks" +"text.edit.preedit" = "Susun teks (IME)" +"text.edit.selectLine" = "Pilih baris" +"text.edit.selectWord" = "Pilih kata" +"text.edit.selection" = "Pilihan teks" +"text.endPreview" = "Akhiri pratinjau teks" +"text.fonts" = "Daftar font" +"text.italic" = "Miring" +"text.lineHeightDown" = "Kurangi tinggi baris" +"text.lineHeightUp" = "Tambah tinggi baris" +"text.link" = "Buat link" +"text.listIndent" = "Tambah indentasi daftar" +"text.listOutdent" = "Kurangi indentasi daftar" +"text.listStyle" = "Gaya daftar" +"text.maxLines" = "Baris maksimum" +"text.numberList" = "Daftar bernomor" +"text.onPath" = "Teks pada path" +"text.paragraphIndent" = "Indentasi paragraf" +"text.paragraphSpacing" = "Spasi paragraf" +"text.pathOptions" = "Opsi path teks" +"text.preview" = "Pratinjau perubahan teks" +"text.removeFromPath" = "Lepaskan teks dari path" +"text.set" = "Atur teks" +"text.setFeatures" = "Fitur OpenType" +"text.setVariations" = "Sumbu font variabel" +"text.sizeDown" = "Perkecil ukuran font" +"text.sizeUp" = "Perbesar ukuran font" +"text.strikethrough" = "Coret" +"text.style" = "Gaya rentang teks" +"text.trackingDown" = "Kurangi spasi huruf" +"text.trackingUp" = "Tambah spasi huruf" +"text.underline" = "Garis bawah" +"text.weightDown" = "Kurangi ketebalan font" +"text.weightUp" = "Tambah ketebalan font" +"tool.arrow" = "Panah" +"tool.comment" = "Komentar" +"tool.ellipse" = "Elips" +"tool.frame" = "Frame" +"tool.hand" = "Tangan" +"tool.line" = "Garis" +"tool.move" = "Pindahkan" +"tool.pen" = "Pena" +"tool.pencil" = "Pensil" +"tool.pencilBrush" = "Kuas pensil" +"tool.place" = "Tempatkan dengan alat" +"tool.polygon" = "Poligon" +"tool.rectangle" = "Persegi panjang" +"tool.scale" = "Skala" +"tool.scaleAnchor" = "Titik acuan skala" +"tool.section" = "Section" +"tool.set" = "Pilih alat" +"tool.shapeBuilder" = "Shape builder" +"tool.slice" = "Slice" +"tool.star" = "Bintang" +"tool.text" = "Teks" +"transform.move" = "Pindahkan" +"transform.moveTo" = "Pindahkan ke" +"transform.nudge" = "Geser sedikit" +"transform.nudgeSize" = "Ubah ukuran dengan geser sedikit" +"transform.resize" = "Ubah ukuran" +"transform.rotate" = "Putar" +"transform.rotationOrigin" = "Pindahkan titik pusat rotasi" +"transform.scale" = "Skala" +"transform.setRotationOrigin" = "Atur titik pusat rotasi" +"variable.addMode" = "Tambah mode" +"variable.bind" = "Ikat variabel" +"variable.create" = "Buat variabel" +"variable.createCollection" = "Buat koleksi variabel" +"variable.delete" = "Hapus variabel" +"variable.deleteCollection" = "Hapus koleksi" +"variable.duplicate" = "Duplikat variabel" +"variable.export" = "Ekspor variabel" +"variable.import" = "Impor variabel" +"variable.list" = "Daftar variabel" +"variable.moveMode" = "Pindahkan mode" +"variable.removeMode" = "Hapus mode" +"variable.renameCollection" = "Ganti nama koleksi" +"variable.renameMode" = "Ganti nama mode" +"variable.resetOverride" = "Reset override variabel" +"variable.set" = "Atur nilai variabel" +"variable.setMode" = "Atur mode variabel" +"variable.update" = "Edit variabel" +"vector.create" = "Vector" +"vector.deleteHeal" = "Hapus dan sambungkan pilihan" +"vector.edit.addVertex" = "Tambah titik" +"vector.edit.begin" = "Edit vector" +"vector.edit.bend" = "Lengkungkan segmen" +"vector.edit.bounds" = "Batas titik terpilih" +"vector.edit.connect" = "Hubungkan titik" +"vector.edit.cornerRadius" = "Radius sudut titik" +"vector.edit.cut" = "Potong path" +"vector.edit.delete" = "Hapus titik" +"vector.edit.end" = "Selesai mengedit" +"vector.edit.erase" = "Hapus sebagian" +"vector.edit.lasso" = "Pilih dengan laso" +"vector.edit.mirroring" = "Pencerminan handle" +"vector.edit.moveHandles" = "Pindahkan handle" +"vector.edit.moveVertices" = "Pindahkan titik" +"vector.edit.nudge" = "Geser sedikit titik" +"vector.edit.paint" = "Ember cat" +"vector.edit.pen" = "Titik pena" +"vector.edit.penEnd" = "Akhiri path pena" +"vector.edit.position" = "Posisi titik" +"vector.edit.resize" = "Ubah ukuran titik" +"vector.edit.rotate" = "Putar titik" +"vector.edit.select" = "Pilih titik" +"vector.edit.selectAll" = "Pilih semua titik" +"vector.edit.setHandle" = "Atur handle" +"vector.edit.smooth" = "Haluskan atau tajamkan titik" +"vector.edit.state" = "Status edit vector" +"vector.edit.strokeCap" = "Ujung stroke titik" +"vector.edit.strokeJoin" = "Sambungan stroke titik" +"vector.edit.tool" = "Alat edit vector" +"vector.edit.transform" = "Transformasi titik" +"vector.edit.widthPoint" = "Titik lebar" +"vector.flatten" = "Satukan" +"vector.join" = "Gabungkan pilihan" +"vector.offset" = "Offset path" +"vector.outlineStroke" = "Outline stroke" +"vector.reverse" = "Balik arah path" +"vector.simplify" = "Sederhanakan vector" +"vector.smoothJoin" = "Gabungkan pilihan dengan halus" +"version.autosave" = "Simpan versi otomatis" +"version.delete" = "Hapus versi" +"version.duplicate" = "Duplikat versi" +"version.exitPreview" = "Kembali ke versi saat ini" +"version.list" = "Daftar versi" +"version.preview" = "Lihat versi" +"version.rename" = "Ganti nama versi" +"version.restore" = "Pulihkan versi" +"view.assets" = "Panel aset" +"view.comments" = "Komentar" +"view.contextMenu" = "Tampilkan menu konteks" +"view.design" = "Panel desain" +"view.designPrototype" = "Beralih antara Desain dan Prototype" +"view.devMode" = "Dev Mode" +"view.frameOutlines" = "Outline frame" +"view.get" = "Ambil tampilan" +"view.layers" = "Panel layer" +"view.layoutGrids" = "Grid tata letak" +"view.leftSidebar" = "Tampilkan/Sembunyikan sidebar kiri" +"view.maskOutlines" = "Outline mask" +"view.multiplayerCursors" = "Kursor multipemain" +"view.nextFrame" = "Zoom ke frame berikutnya" +"view.nextPage" = "Halaman berikutnya" +"view.nextRegion" = "Fokus ke area berikutnya" +"view.outlines" = "Garis luar" +"view.outlinesHiddenLayers" = "Sertakan layer tersembunyi di outline" +"view.outlinesObjectBounds" = "Sertakan batas objek di outline" +"view.outlinesSelection" = "Outline untuk pilihan" +"view.pan" = "Geser tampilan" +"view.panelWidths" = "Reset lebar panel" +"view.pixelGrid" = "Grid piksel" +"view.pixelPreview" = "Pratinjau piksel" +"view.prevFrame" = "Zoom ke frame sebelumnya" +"view.prevPage" = "Halaman sebelumnya" +"view.previousRegion" = "Fokus ke area sebelumnya" +"view.proofSrgb" = "Soft-proof sRGB" +"view.fullscreen" = "Layar penuh" +"view.prototype" = "Panel prototype" +"view.rulers" = "Penggaris" +"view.set" = "Atur tampilan" +"view.slices" = "Slice" +"view.snapGeometry" = "Lekatkan ke geometri" +"view.snapObjects" = "Lekatkan ke objek" +"view.snapPixel" = "Snap ke grid piksel" +"view.themeDark" = "Tema gelap" +"view.themeLight" = "Tema terang" +"view.themeSystem" = "Ikuti tema sistem" +"view.toggleUi" = "Tampilkan/Sembunyikan UI" +"view.zoom100" = "Zoom ke 100%" +"view.zoom200" = "Zoom ke 200%" +"view.zoom50" = "Zoom ke 50%" +"view.zoomAt" = "Alat zoom" +"view.zoomFit" = "Zoom agar pas" +"view.zoomIn" = "Perbesar" +"view.zoomOut" = "Perkecil" +"view.zoomSelection" = "Zoom ke pilihan" +"widget.click" = "Klik widget" +"widget.insert" = "Sisipkan widget" +"widget.list" = "Daftar widget" +"window.focus" = "Bawa jendela ke depan" +"window.new" = "Jendela baru" +"template.list" = "Daftar templat" + +[menu] +"bool.exclude" = "Kecualikan" +"bool.flatten" = "Satukan" +"bool.intersect" = "Iriskan" +"bool.subtract" = "Kurangi" +"bool.union" = "Gabungkan" +"file.duplicate" = "Duplikat" +"file.import" = "Impor dari file…" +"file.rename" = "Ganti nama" +"libraries.open" = "Buka pustaka" +"select.sameEffect" = "Efek" +"select.sameFill" = "Fill" +"select.sameFont" = "Font" +"select.sameInstance" = "Instance" +"select.sameProperties" = "Properti" +"select.sameStroke" = "Stroke" +"select.sameText" = "Properti teks" +"text.alignCenter" = "Rata tengah teks" +"text.alignJustify" = "Rata kiri-kanan teks" +"text.alignLeft" = "Rata kiri teks" +"text.alignRight" = "Rata kanan teks" +"view.assets" = "Aset" +"view.design" = "Desain" +"view.layers" = "Layer" +"view.outlines" = "Tampilkan outline" +"view.outlinesHiddenLayers" = "Sertakan layer tersembunyi" +"view.outlinesObjectBounds" = "Sertakan batas objek" +"view.outlinesSelection" = "Tampilkan outline untuk pilihan" +"view.prototype" = "Prototype" +"view.themeDark" = "Gelap" +"view.themeLight" = "Terang" +"view.themeSystem" = "Ikuti sistem" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index f7b9cf9fd..4d4d6d340 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -19,7 +19,11 @@ 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 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 title, an agenda, a big figure, a chart and a closing slide" = "タイトル、目次、大きな数値、グラフ、締めのスライド" +"A to Z" = "昇順(A→Z)" "About" = "情報" "About FigCraft" = "FigCraft について" "About this server" = "このサーバーについて" @@ -90,7 +94,9 @@ status = "draft — needs native review" "All supported properties" = "サポートされているすべてのプロパティ" "All variables" = "すべての変数" "Alpha" = "アルファ" +"Alphabetical" = "名前順" "Alt+drag" = "Alt+ドラッグ" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "グリッドに沿った分析画面:数値、ベクターで描いたグラフ、表" "An invite link that opens FigCraft and joins" = "FigCraft を開いて参加する招待リンク" "Anchor" = "アンカー" "Android" = "Android" @@ -109,6 +115,8 @@ status = "draft — needs native review" "Apply variable" = "変数を適用" "Apply variable to text" = "テキストに変数を適用" "Apply variable to visibility" = "表示に変数を適用" +"Apps" = "アプリ" +"Apr" = "4月" "Arc" = "円弧" "Arc ratio" = "円弧の比率" "Arc start" = "円弧の開始" @@ -120,6 +128,7 @@ status = "draft — needs native review" "As typed" = "入力どおり" "Asking the server…" = "サーバーに問い合わせ中…" "Assets" = "アセット" +"Aug" = "8月" "Auto" = "自動" "Auto (space around)" = "自動(周囲に間隔)" "Auto (space between)" = "自動(間に間隔)" @@ -164,6 +173,7 @@ status = "draft — needs native review" "Bottom" = "下" "Bottom left radius" = "左下の半径" "Bottom right radius" = "右下の半径" +"Bound to {name}. Click to detach the variable." = "「{name}」を適用中。クリックで変数の割り当てを解除。" "Branch" = "ブランチ" "Branch archived" = "ブランチをアーカイブ済み" "Branch merged" = "ブランチをマージ済み" @@ -250,6 +260,7 @@ status = "draft — needs native review" "Color values in this file are in this color space." = "このファイルのカラー値はこのカラースペースで表される。" "Color variables" = "カラー変数" "Colour" = "カラー" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "カラー・テキストスタイル、ライト・ダークの変数、ボタン、入力欄、カード" "Column" = "列" "Column gap" = "列の間隔" "Column sizes" = "列のサイズ" @@ -303,6 +314,7 @@ status = "draft — needs native review" "Corner radius" = "角の半径" "Corner smoothing" = "角のスムージング" "Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。" +"Couldn't save automatically: {error}" = "自動保存できませんでした:{error}" "Count" = "数" "Create" = "作成" "Create a variable collection first" = "先に変数コレクションを作成" @@ -316,6 +328,7 @@ status = "draft — needs native review" "Create property…" = "プロパティを作成…" "Create style" = "スタイルを作成" "Create style from the selected text" = "選択したテキストからスタイルを作成" +"Created" = "作成日" "Crop" = "切り抜き" "Crop marks" = "トンボ" "Ctrl+arrows" = "Ctrl+矢印キー" @@ -329,6 +342,9 @@ status = "draft — needs native review" "Dark" = "ダーク" "Darken" = "比較(暗)" "Dash" = "破線" +"Dashboard" = "ダッシュボード" +"Date created" = "作成日" +"Dec" = "12月" "Decoration style" = "装飾のスタイル" "Decoration" = "装飾" "Deep select" = "深い階層を選択" @@ -355,6 +371,7 @@ status = "draft — needs native review" "Description" = "説明" "Description (optional)" = "説明(任意)" "Design" = "デザイン" +"Design system starter" = "デザインシステムの雛形" "Desktop" = "デスクトップ" "Destination" = "遷移先" "Detach alias" = "エイリアスを解除" @@ -390,6 +407,7 @@ status = "draft — needs native review" "Drafts" = "下書き" "Drag the stroke to add a width point" = "線をドラッグして幅のポイントを追加" "Draw" = "描画" +"Draw a frame" = "フレームを描く" "Drawing tools" = "描画ツール" "Drop shadow" = "ドロップシャドウ" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "MIT または Apache-2.0 のデュアルライセンス。ArtCraft チームとコミュニティが制作。" @@ -408,6 +426,7 @@ status = "draft — needs native review" "Edit style" = "スタイルを編集" "Edit variable" = "変数を編集" "Edit with the builder" = "ビルダーで編集" +"Edited {when}" = "最終編集:{when}" "Effect settings" = "エフェクトの設定" "Effect styles" = "エフェクトスタイル" "Effects" = "エフェクト" @@ -438,6 +457,7 @@ status = "draft — needs native review" "Expand" = "展開" "Export" = "書き出し" "Export all" = "すべて書き出し" +"Export frames and layers as PNG, JPG, SVG or PDF" = "フレームやレイヤーを PNG、JPG、SVG、PDF で書き出し" "Export page as PNG" = "ページを PNG で書き出し" "Export settings" = "書き出し設定" "Export variables" = "変数を書き出し" @@ -457,9 +477,12 @@ status = "draft — needs native review" "F" = "F" "Fade" = "フェード" "False" = "false" +"Feb" = "2月" "Fetching from the server…" = "サーバーから取得中…" +"FigCraft couldn't open its window: {error}" = "FigCraft のウィンドウを開けなかった:{error}" "File" = "ファイル" "File name" = "ファイル名" +"File not found" = "ファイルが見つからない" "File published" = "ファイルを公開済み" "Files aren't end-to-end encrypted: the server's operator can read them." = "ファイルはエンドツーエンドで暗号化されない:サーバーの運営者は内容を読める。" "Files nobody opens for {n} days are deleted." = { other = "{n} 日間誰も開かなかったファイルは削除される。" } @@ -506,6 +529,7 @@ status = "draft — needs native review" "Font size" = "フォントサイズ" "Font style" = "フォントスタイル" "Forget on this computer" = "このコンピューターで記録を消去" +"Four screens of a trail app with components and a prototype flow" = "コンポーネントとプロトタイプのフローを備えた登山アプリの4画面" "Fractions" = "分数" "Frame" = "フレーム" "Frame selection" = "選択範囲をフレームで囲む" @@ -519,6 +543,7 @@ status = "draft — needs native review" "Gap between columns" = "列の間隔" "Gap between items" = "アイテムの間隔" "Gap between rows" = "行の間隔" +"Getting started" = "使い始めるには" "Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "一般公開されているサーバーのアドレス、またはキー付きのサーバーのアドレスを入力(`figcraft-server key` で表示)。" "Glass" = "ガラス" "Go to main component" = "メインコンポーネントへ移動" @@ -527,6 +552,7 @@ status = "draft — needs native review" "Gradient" = "グラデーション" "Gray" = "グレー" "Green" = "緑" +"Greyscale components and two example screens to sketch a flow with" = "フローを考えるためのグレースケールのコンポーネントと2つの画面例" "Grid" = "グリッド" "Grid ({size})" = "グリッド({size})" "Grid styles" = "グリッドスタイル" @@ -548,6 +574,8 @@ status = "draft — needs native review" "Hide contents" = "コンテンツを非表示" "Hide from publishing" = "公開対象から除外" "Hide section contents" = "セクションのコンテンツを非表示" +"Hide these tips (Getting started in the Help menu shows them again)" = "ヒントを非表示(ヘルプメニューの「使い始めるには」で再表示)" +"Hide when publishing" = "公開時に非表示" "Highlight layers on hover" = "ホバー時にレイヤーをハイライト" "Highlights" = "ハイライト" "Hold Alt" = "Alt を押したまま" @@ -569,6 +597,9 @@ status = "draft — needs native review" "Hue" = "色相" "Hug" = "ハグ" "Hug contents" = "内容に合わせる" +"Icon components on a 24 px grid with keylines and a size preview" = "24 px グリッドのアイコンコンポーネント、キーライン、サイズのプレビュー" +"Icon set starter" = "アイコンセットの雛形" +"If it keeps happening, please report it at {link} with this message." = "繰り返し起きる場合は、このメッセージを添えて {link} に報告。" "If" = "条件" "If {condition}" = "条件:{condition}" "Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)" @@ -613,6 +644,7 @@ status = "draft — needs native review" "Invite link" = "招待リンク" "Invite link copied" = "招待リンクをコピー済み" "It asks permission to:" = "次の権限を求めている:" +"Jan" = "1月" "Join" = "参加" "Join code copied" = "参加コードをコピー済み" "Join code or invite link" = "参加コードまたは招待リンク" @@ -623,6 +655,8 @@ status = "draft — needs native review" "Joined {file}" = "{file} に参加済み" "Joining…" = "参加中…" "Join|stroke" = "角の形状" +"Jul" = "7月" +"Jun" = "6月" "Just now" = "たった今" "Justify" = "両端揃え" "Keep appearance" = "見た目を保持" @@ -632,11 +666,14 @@ status = "draft — needs native review" "Key/Gamepad" = "キー/ゲームパッド" "Key/Gamepad {key}" = "キー/ゲームパッド {key}" "Keyboard shortcuts" = "キーボードショートカット" +"Kits" = "キット" +"Landing page" = "ランディングページ" "Language" = "言語" "Lasso" = "なげなわ" "Last" = "最後" "Last change: {note}" = "最後の変更:{note}" "Last commit" = "最後のコミット" +"Last modified" = "更新日" "Last on top" = "最後を上に" "Layer blur" = "レイヤーのぼかし" "Layer name" = "レイヤー名" @@ -675,6 +712,7 @@ status = "draft — needs native review" "Link" = "リンク" "Link copied" = "リンクをコピー済み" "Link docs, code or tickets for developers." = "開発者向けにドキュメント、コード、チケットをリンク。" +"Link frames on the Prototype tab, then present the flow" = "プロトタイプタブでフレームをつなぎ、フローを再生" "Link this component to its code component: instances then show your real snippet with their values." = "このコンポーネントをコードコンポーネントにリンクすると、インスタンスに実際のスニペットがその値とともに表示される。" "List spacing" = "リストの間隔" "List style" = "リストのスタイル" @@ -692,7 +730,9 @@ status = "draft — needs native review" "Main" = "メイン" "Main component deleted" = "メインコンポーネントは削除済み" "Main menu" = "メインメニュー" +"Make a component" = "コンポーネントを作成" "Make slot" = "スロットにする" +"Mar" = "3月" "Margin" = "マージン" "Mark all as read" = "すべて既読にする" "Mark as resolved" = "解決済みにする" @@ -705,6 +745,7 @@ status = "draft — needs native review" "Max W" = "最大 W" "Max lines" = "最大行数" "Maximize" = "最大化" +"May" = "5月" "Measure to the selection" = "選択範囲までの距離を測定" "Merge into main" = "メインにマージ" "Merge regions" = "領域を結合" @@ -726,6 +767,7 @@ status = "draft — needs native review" "Mixed" = "混在" "Mixed fonts" = "混在フォント" "Mixed interactions: click + to replace them" = "混在したインタラクション:+ をクリックして置き換え" +"Mobile app" = "モバイルアプリ" "Mode" = "モード" "Model" = "モデル" "Models" = "モデル" @@ -755,6 +797,7 @@ status = "draft — needs native review" "Navigate to" = "移動先" "Nested instance" = "ネストされたインスタンス" "New design file" = "新規デザインファイル" +"Newest first" = "新しい順" "Next or previous control in the focused region" = "フォーカス中の領域で次または前のコントロール" "Next ready for dev" = "次の開発準備完了" "Next size option" = "次のサイズオプション" @@ -797,6 +840,7 @@ status = "draft — needs native review" "No string variables yet. Create them in Local variables." = "文字列変数はまだない。ローカル変数で作成。" "No styles yet." = "スタイルはまだない。" "No styles yet. Click + to create one from the selection." = "スタイルはまだない。+ をクリックして選択範囲から作成。" +"No templates match your search" = "検索に一致するテンプレートなし" "No text styles yet. Click + to create one from the selected text." = "テキストスタイルはまだない。+ をクリックして選択したテキストから作成。" "No variables fit here." = "ここに使える変数はない。" "No variables yet. Create them in Local variables." = "変数はまだない。ローカル変数で作成。" @@ -816,6 +860,7 @@ status = "draft — needs native review" "Nothing here yet. Set a frame's status to list it." = "まだ何もない。フレームのステータスを設定すると一覧に表示される。" "Nothing online yet. Use Share on a file to put it online and it is listed here." = "オンラインのファイルはまだない。ファイルの「共有」からオンラインにすると、ここに表示される。" "Nothing to export" = "書き出すものがない" +"Nov" = "11月" "Now" = "現在" "Nudge (Shift: big nudge)" = "微調整(Shift:大きく微調整)" "Nudge amount" = "微調整の量" @@ -824,13 +869,16 @@ status = "draft — needs native review" "Number ↓" = "番号 ↓" "Numbered list" = "番号付きリスト" "Object" = "オブジェクト" +"Oct" = "10月" "Off" = "オフ" "Offered first when filling the slot (any content is allowed)" = "スロットを埋めるときに最初に提示(どのコンテンツも使用可)" "Offered first when swapping the instance (any component is allowed)" = "インスタンスを入れ替えるときに最初に提示(どのコンポーネントも使用可)" "Offset" = "オフセット" +"Oldest first" = "古い順" "On click" = "クリック時" "On drag" = "ドラッグ時" "On this page" = "このページ" +"One announcement in five common sizes: square, portrait, story, link and banner" = "1つのお知らせを5種類の一般的なサイズで:正方形、縦長、ストーリー、リンク、バナー" "Online" = "オンライン" "Online files" = "オンラインファイル" "Online sharing is off: files are shared on the local network only." = "オンライン共有はオフ:ファイルはローカルネットワークでのみ共有される。" @@ -857,6 +905,7 @@ status = "draft — needs native review" "Open|comments" = "未解決" "Options" = "オプション" "Orange" = "オレンジ" +"Order" = "並び順" "Other apps may not open every .fig file exported here." = "ここで書き出した .fig ファイルは、他のアプリで開けない場合がある。" "Others lose access and keep their copy; the file stays on the server for you" = "他の人はアクセスできなくなり、各自のコピーは残る。ファイルはあなた用にサーバーに残る" "Others type this under Share ▸ Join with a code" = "他の人は「共有 ▸ コードで参加」でこれを入力" @@ -903,6 +952,7 @@ status = "draft — needs native review" "Pick color" = "色を抽出" "Pick color (I)" = "色を抽出(I)" "Pick one font family first" = "先にフォントファミリーを 1 つ選択" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "フレームツールを選び、キャンバスをドラッグするか端末のサイズを選択" "Pin a property" = "プロパティを固定" "Pinch" = "すぼめる" "Pixel grid" = "ピクセルグリッド" @@ -911,6 +961,7 @@ status = "draft — needs native review" "Place a shape or frame (its tool chosen)" = "シェイプまたはフレームを配置(ツール選択中)" "Place automatically" = "自動で配置" "Place image" = "画像を配置" +"Playground" = "プレイグラウンド" "Plugins" = "プラグイン" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "プラグインは FigCraft のサンドボックス内で、ファイル、ネットワーク、時計なしで実行される。信頼できるプラグインのみをインストールすること。" "Plus darker" = "プラス(暗)" @@ -928,6 +979,7 @@ status = "draft — needs native review" "Present or preview" = "プレゼンテーションまたはプレビュー" "Present this flow" = "このフローをプレゼンテーション" "Presentation" = "プレゼンテーション" +"Presentation slides" = "プレゼンテーションのスライド" "Presenting: comments" = "プレゼンテーション中:コメント" "Presenting: cycle the size options" = "プレゼンテーション中:サイズオプションを切り替え" "Presenting: full screen" = "プレゼンテーション中:全画面表示" @@ -983,6 +1035,7 @@ status = "draft — needs native review" "Remove auto layout" = "オートレイアウトを解除" "Remove dev resource" = "開発リソースを削除" "Remove export setting" = "書き出し設定を削除" +"Remove from recents" = "最近使ったファイルから削除" "Remove from server" = "サーバーから削除" "Remove from server…" = "サーバーから削除…" "Remove guide" = "ガイドを削除" @@ -999,6 +1052,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "「それ以外」とそのアクションを削除" "Remove this condition and its actions" = "この条件とそのアクションを削除" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "このファイルをサーバーから削除しますか? サーバーからの共有や再オープンはできなくなる。手元のコピーは開いたまま。" +"Remove {name} from session" = "{name} をセッションから削除" "Remove {name} from {server}?" = "{name} を {server} から削除しますか?" "Removed" = "削除" "Removed from the server" = "サーバーから削除済み" @@ -1072,6 +1126,7 @@ status = "draft — needs native review" "Save" = "保存" "Save as" = "名前を付けて保存" "Save as preset…" = "プリセットとして保存…" +"Save files automatically" = "ファイルを自動保存" "Save version {key}" = "バージョンを保存 {key}" "Saved" = "保存済み" "Saved {name}" = "{name} を保存済み" @@ -1092,15 +1147,18 @@ status = "draft — needs native review" "Search layers" = "レイヤーを検索" "Search plugins" = "プラグインを検索" "Search shortcuts" = "ショートカットを検索" +"Search templates" = "テンプレートを検索" "Section" = "セクション" "Section color" = "セクションの色" "Sections" = "セクション" +"See all templates" = "すべてのテンプレートを見る" "Select a frame and add a starting point to present it as a flow." = "フレームを選択して開始点を追加すると、フローとしてプレゼンテーションできる。" "Select a frame to preview it" = "フレームを選択するとプレビュー" "Select a layer to see its properties and code" = "レイヤーを選択するとプロパティとコードを表示" "Select all" = "すべて選択" "Select all with same" = "同じ項目をすべて選択" "Select layer" = "レイヤーを選択" +"Select layers and they space themselves as their content changes" = "レイヤーを選択すると、内容の変化に合わせて間隔を自動調整" "Select layers to apply the style to" = "スタイルを適用するレイヤーを選択" "Select overlapping shapes" = "重なったシェイプを選択" "Select points to edit their position, radius and mirroring" = "ポイントを選択すると位置、半径、対称を編集できる" @@ -1111,6 +1169,7 @@ status = "draft — needs native review" "Send backward" = "背面へ" "Send report" = "報告を送信" "Send to back" = "最背面へ" +"Sep" = "9月" "Server address" = "サーバーのアドレス" "Server address (ws://host:47900/)" = "サーバーのアドレス(ws://host:47900/)" "Server for online sharing" = "オンライン共有用のサーバー" @@ -1125,6 +1184,7 @@ status = "draft — needs native review" "Shape tools" = "シェイプツール" "Share" = "共有" "Share this file" = "このファイルを共有" +"Share with a code" = "コードで共有" "Share: live session, export" = "共有:ライブセッション、書き出し" "Shared" = "共有中" "Shared: copy its code from the file's menu" = "共有済み:コードはファイルのメニューからコピー" @@ -1134,6 +1194,7 @@ status = "draft — needs native review" "Sharing online · {people}" = "オンラインで共有中 · {people}" "Sharing stopped: the file stays on the server, open only to you" = "共有を停止:ファイルはサーバーに残り、開けるのはあなただけ" "Show" = "表示" +"Show all templates" = "すべてのテンプレートを表示" "Show as grid" = "グリッドで表示" "Show as list" = "リストで表示" "Show autosaves" = "自動保存を表示" @@ -1142,6 +1203,9 @@ status = "draft — needs native review" "Show device frame" = "デバイスフレームを表示" "Show dimensions on objects" = "オブジェクトに寸法を表示" "Show hotspot hints" = "ホットスポットのヒントを表示" +"Show in Explorer" = "エクスプローラーで表示" +"Show in Finder" = "Finder で表示" +"Show in folder" = "フォルダーで表示" "Show only this on the canvas" = "キャンバスにこれだけを表示" "Show open" = "未解決を表示" "Show section contents" = "セクションのコンテンツを表示" @@ -1166,13 +1230,17 @@ status = "draft — needs native review" "Small caps" = "スモールキャップ" "Smart animate" = "スマートアニメート" "Snap to pixel grid" = "ピクセルグリッドにスナップ" +"Social and slides" = "SNS・スライド" "Social media" = "ソーシャルメディア" +"Social post sizes" = "SNS投稿サイズ" "Soft light" = "ソフトライト" "Solid" = "実線" "Some variants have the same property values: give each one a unique combination" = "同じプロパティ値を持つバリアントがある:それぞれ固有の組み合わせにする" "Sort" = "並べ替え" +"Sort by" = "並べ替えの基準" "Sort by date" = "日付で並べ替え" "Sort by page" = "ページで並べ替え" +"Sort by {column}" = "{column}で並べ替え" "Space around" = "周囲に均等配置" "Space between" = "間に均等配置" "Space drag" = "Space ドラッグ" @@ -1191,9 +1259,11 @@ status = "draft — needs native review" "Start" = "開始" "Start ascending from / stop descending at" = "昇順の開始値 / 降順の終了値" "Start from a blank canvas" = "空白のキャンバスから始める" +"Start from a template" = "テンプレートから始める" "Start on path" = "パス上の開始位置" "Start point" = "始点" "Start sharing" = "共有を開始" +"Start sharing with the Share button, then pass on the code it gives" = "共有ボタンで共有を開始し、表示されたコードを相手に伝える" "Starting to share…" = "共有を開始中…" "Status" = "ステータス" "Sticky" = "固定" @@ -1242,6 +1312,7 @@ status = "draft — needs native review" "Tell the server's operator what is wrong with it" = "何が問題かをサーバーの運営者に伝える" "Temperature" = "色温度" "Template" = "テンプレート" +"Templates" = "テンプレート" "Terms" = "利用規約" "Text" = "テキスト" "Text align" = "テキストの配置" @@ -1299,6 +1370,8 @@ status = "draft — needs native review" "Truncate with an ellipsis" = "省略記号で切り詰める" "Try again" = "再試行" "Try another search or filter." = "別の検索語やフィルターを試す。" +"Try another word, or look at every kind." = "別の単語で検索するか、すべての種類を表示。" +"Turn a layer into a component, reuse it, and change every copy at once" = "レイヤーをコンポーネントに変換して再利用し、すべてのコピーを一括で変更" "Two digits" = "2 桁" "Type" = "種類" "Type settings" = "文字の設定" @@ -1324,6 +1397,8 @@ status = "draft — needs native review" "Update plugin" = "プラグインを更新" "Updated {components} and {styles}" = "{components}と{styles}を更新済み" "Updated {name}" = "{name} を更新済み" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "グラフィックスドライバーの更新で解決することが多い。OpenGL だけを使うには、コマンドプロンプトから次のように FigCraft を起動:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "グラフィックスドライバーの更新で解決することが多い。WGPU_BACKEND(vulkan、gl、metal)で別のグラフィックス API を試せる。" "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "このファイルを FigCraft サーバーにアップロードし、このタブを同期した状態に保つ。編集用リンクと閲覧用リンクが発行される。" "Uppercase" = "大文字" "Use a fixed value" = "固定値を使用" @@ -1334,6 +1409,7 @@ status = "draft — needs native review" "Use the layer's fill" = "レイヤーの塗りを使用" "Used in {files} · {instances}" = "{files}で使用 · {instances}" "Username" = "ユーザー名" +"Uses variables: each layer shows them in its own mode" = "変数を使用:各レイヤーに設定されたモードで表示" "Value" = "値" "Variable" = "変数" "Variable collections" = "変数コレクション" @@ -1365,18 +1441,21 @@ status = "draft — needs native review" "Visibility" = "表示" "Visible" = "表示" "Vivid" = "ビビッド" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND が設定されていて、使うグラフィックス API を指定している。解除すると FigCraft が選ぶ。" "Warm" = "ウォーム" "Watch" = "ウォッチ" "Wavy" = "波線" "Web" = "Web" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL をコピー済み" +"Websites" = "Webサイト" "Wedge" = "くさび" "Weight" = "太さ" "What changed since it was marked ready for dev" = "開発準備完了にしてからの変更内容" "What is wrong with this file? The server's operator gets your report." = "このファイルの何が問題ですか? 報告はサーバーの運営者に届く。" "What it's for" = "用途" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "変更前の状態は不明:FigCraft が開発準備完了フレームのコピーを保存するようになる前に変更された。変更を解決する(または再度準備完了にする)と、今後はそこから比較する。" +"What you try here changes this preview and the code below, not the file" = "ここでの操作はプレビューと下のコードに反映。ファイルは変更されない" "While hovering" = "ホバー中" "While pressing" = "押している間" "White" = "白" @@ -1386,6 +1465,7 @@ status = "draft — needs native review" "Width" = "幅" "Width point" = "幅のポイント" "Width profile" = "幅プロファイル" +"Wireframe kit" = "ワイヤーフレームキット" "With the layers' export settings" = "レイヤーの書き出し設定で" "Wrap" = "折り返し" "Wrap in new section" = "新しいセクションで囲む" @@ -1400,6 +1480,7 @@ status = "draft — needs native review" "Your name in comments" = "コメントに表示する名前" "Your system asks for reduced motion: animations stay off." = "システムで動きを減らす設定がオンのため、アニメーションはオフのまま。" "Z" = "Z" +"Z to A" = "降順(Z→A)" "Zoom" = "ズーム" "Zoom around the pointer" = "ポインターを中心にズーム" "Zoom device to fill window" = "デバイスをウィンドウいっぱいにズーム" @@ -1426,6 +1507,7 @@ status = "draft — needs native review" "from {server}" = "{server} から" "hidden" = "非表示" "iOS" = "iOS" +"just now" = "たった今" "locked" = "ロック中" "no bleed" = "裁ち落としなし" "nobody else here yet" = "まだ他に誰もいない" @@ -1437,9 +1519,11 @@ status = "draft — needs native review" "reads the document" = "ドキュメントを読み取る" "the plugin" = "このプラグイン" "this session only" = "このセッションのみ" +"yesterday" = "昨日" "{instances} in {files}" = "{files}内の{instances}" "{language}: draft translation, waiting for a native speaker's review." = "{language}:下書きの翻訳。ネイティブスピーカーによるレビュー待ち。" "{length} bleed" = "裁ち落とし {length}" +"{month} {day}, {year}" = "{year}年{month}{day}日" "{name}\nClick to apply to the selection" = "{name}\nクリックして選択範囲に適用" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nキャンバスにドラッグするか、クリックしてビューの中央に配置。\nインスタンスの上にドロップするときに Alt(Option)を押したままにすると入れ替え。" "{name} (from {library})" = "{name}({library} から)" @@ -1570,6 +1654,7 @@ status = "draft — needs native review" "collab.publish" = "サーバーに公開" "collab.recoverFile" = "オンラインファイルを復元" "collab.registerLinks" = "招待リンクを FigCraft で開く" +"collab.removePerson" = "セッションから削除" "collab.report" = "ファイルをサーバーの運営者に報告" "collab.serverInfo" = "サーバー情報" "collab.share" = "参加コードで共有" @@ -1601,8 +1686,10 @@ status = "draft — needs native review" "component.editProperty" = "コンポーネントプロパティを編集" "component.exposeInstance" = "ネストされたインスタンスを公開" "component.goToMain" = "メインコンポーネントへ移動" +"component.hideWhenPublishing" = "公開時に非表示" "component.list" = "コンポーネントを一覧表示" "component.makeSlot" = "スロットにする" +"component.playground" = "コンポーネントを試す" "component.properties" = "コンポーネントプロパティ" "component.pushChanges" = "変更をメインコンポーネントにプッシュ" "component.removeSlot" = "スロットを削除" @@ -1660,6 +1747,7 @@ status = "draft — needs native review" "file.exportSvg" = "選択範囲を SVG で書き出し" "file.import" = "インポート" "file.new" = "新規デザインファイル" +"file.newFromTemplate" = "テンプレートから新規作成" "file.open" = "開く" "file.openRecent" = "最近使ったファイルを開く" "file.placeImage" = "画像を配置" @@ -1687,6 +1775,7 @@ status = "draft — needs native review" "guide.remove" = "ガイドを削除" "guide.select" = "ガイドを選択" "help.about" = "FigCraft について" +"help.gettingStarted" = "使い始めるには" "help.page" = "ヘルプページ" "help.shortcuts" = "キーボードショートカット" "image.adjust" = "画像を調整" @@ -1740,6 +1829,8 @@ status = "draft — needs native review" "node.reparent" = "レイヤーを中に移動" "node.set" = "プロパティを設定" "object.batchRename" = "一括で名前を変更" +"object.convertToFrame" = "フレームに変換" +"object.convertToGroup" = "グループに変換" "object.flipH" = "左右反転" "object.flipV" = "上下反転" "object.frameSelection" = "選択範囲をフレームで囲む" @@ -1786,6 +1877,7 @@ status = "draft — needs native review" "plugin.ui" = "プラグインパネル" "plugins.manage" = "プラグインを管理…" "plugins.runLast" = "最後のプラグインを実行" +"pref.autoSave" = "ファイルを自動保存" "pref.highlightHover" = "ホバー時にレイヤーをハイライト" "pref.keepTool" = "使用後もツールを選択したままにする" "pref.language" = "インターフェースの言語" @@ -1855,6 +1947,7 @@ status = "draft — needs native review" "stroke.set" = "線を設定" "stroke.widthProfile" = "幅プロファイル" "style.apply" = "スタイルを適用" +"style.bind" = "スタイルに変数を割り当て" "style.create" = "スタイルを作成" "style.delete" = "スタイルを削除" "style.detach" = "スタイルを切り離す" @@ -1863,6 +1956,7 @@ status = "draft — needs native review" "style.move" = "スタイルを移動" "style.renameFolder" = "スタイルフォルダー名を変更" "style.update" = "スタイルを編集" +"template.list" = "テンプレートを一覧表示" "text.alignCenter" = "中央揃え" "text.alignJustify" = "両端揃え" "text.alignLeft" = "左揃え" @@ -2028,8 +2122,11 @@ status = "draft — needs native review" "view.nextPage" = "次のページ" "view.nextRegion" = "次の領域にフォーカス" "view.outlines" = "アウトライン" +"view.outlinesHiddenLayers" = "アウトラインに非表示のレイヤーを含める" +"view.outlinesObjectBounds" = "アウトラインにオブジェクトの境界を含める" "view.outlinesSelection" = "選択範囲のアウトライン" "view.pan" = "ビューをパン" +"view.panelWidths" = "パネルの幅をリセット" "view.pixelGrid" = "ピクセルグリッド" "view.pixelPreview" = "ピクセルプレビュー" "view.prevFrame" = "前のフレームにズーム" @@ -2087,6 +2184,8 @@ status = "draft — needs native review" "view.design" = "デザイン" "view.layers" = "レイヤー" "view.outlines" = "アウトラインを表示" +"view.outlinesHiddenLayers" = "非表示のレイヤーを含める" +"view.outlinesObjectBounds" = "オブジェクトの境界を含める" "view.outlinesSelection" = "選択範囲のアウトラインを表示" "view.prototype" = "プロトタイプ" "view.themeDark" = "ダーク" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 59bb23868..ec732905c 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 정보" @@ -302,6 +303,7 @@ status = "draft — needs native review" "Corner radius" = "모서리 반경" "Corner smoothing" = "모서리 스무딩" "Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함." +"Couldn't save automatically: {error}" = "자동으로 저장할 수 없음: {error}" "Count" = "개수" "Create" = "만들기" "Create a variable collection first" = "먼저 변수 컬렉션을 만드세요" @@ -457,6 +459,7 @@ status = "draft — needs native review" "Fade" = "페이드" "False" = "거짓" "Fetching from the server…" = "서버에서 가져오는 중…" +"FigCraft couldn't open its window: {error}" = "FigCraft 창을 열 수 없음: {error}" "File" = "파일" "File name" = "파일 이름" "File published" = "파일 게시됨" @@ -568,6 +571,7 @@ status = "draft — needs native review" "Hue" = "색조" "Hug" = "맞춤" "Hug contents" = "내용에 맞춤" +"If it keeps happening, please report it at {link} with this message." = "계속 발생하면 이 메시지와 함께 {link}에 알려 주세요." "If" = "조건" "If {condition}" = "조건: {condition}" "Ignore auto layout (absolute position)" = "자동 레이아웃 무시(절대 위치)" @@ -998,6 +1002,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "\"그 외\"와 해당 액션 제거" "Remove this condition and its actions" = "이 조건과 해당 액션 제거" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "이 파일을 서버에서 제거할까요? 서버에서 다시 공유하거나 열 수 없게 됨. 내 사본은 여기 열린 채로 유지됨." +"Remove {name} from session" = "{name} 님을 세션에서 제거" "Remove {name} from {server}?" = "{name}을(를) {server}에서 제거할까요?" "Removed" = "제거됨" "Removed from the server" = "서버에서 제거됨" @@ -1071,6 +1076,7 @@ status = "draft — needs native review" "Save" = "저장" "Save as" = "다른 이름으로 저장" "Save as preset…" = "프리셋으로 저장…" +"Save files automatically" = "파일 자동 저장" "Save version {key}" = "버전 저장 {key}" "Saved" = "저장됨" "Saved {name}" = "{name} 저장됨" @@ -1323,6 +1329,8 @@ status = "draft — needs native review" "Update plugin" = "플러그인 업데이트" "Updated {components} and {styles}" = "{components} 및 {styles} 업데이트됨" "Updated {name}" = "{name} 업데이트됨" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "그래픽 드라이버를 업데이트하면 해결되는 경우가 많습니다. OpenGL만 사용하려면 명령 프롬프트에서 다음과 같이 FigCraft를 시작하세요:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "그래픽 드라이버를 업데이트하면 해결되는 경우가 많습니다. WGPU_BACKEND(vulkan, gl, metal)로 다른 그래픽 API를 시도할 수 있습니다." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "이 파일을 FigCraft 서버에 업로드하고 이 탭을 동기화된 상태로 유지함. 편집 링크와 보기 링크가 생성됨." "Uppercase" = "대문자" "Use a fixed value" = "고정 값 사용" @@ -1364,6 +1372,7 @@ status = "draft — needs native review" "Visibility" = "표시 여부" "Visible" = "표시됨" "Vivid" = "선명하게" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND가 설정되어 사용할 그래픽 API를 지정하고 있습니다. 설정을 해제하면 FigCraft가 선택합니다." "Warm" = "따뜻하게" "Watch" = "시계" "Wavy" = "물결선" @@ -1569,6 +1578,7 @@ status = "draft — needs native review" "collab.publish" = "서버에 게시" "collab.recoverFile" = "온라인 파일 복구" "collab.registerLinks" = "초대 링크를 FigCraft에서 열기" +"collab.removePerson" = "세션에서 제거" "collab.report" = "서버 운영자에게 파일 신고" "collab.serverInfo" = "서버 정보" "collab.share" = "참여 코드로 공유" @@ -1785,6 +1795,7 @@ status = "draft — needs native review" "plugin.ui" = "플러그인 패널" "plugins.manage" = "플러그인 관리…" "plugins.runLast" = "마지막 플러그인 실행" +"pref.autoSave" = "파일 자동 저장" "pref.highlightHover" = "마우스를 올리면 레이어 강조" "pref.keepTool" = "사용 후에도 도구 선택 유지" "pref.language" = "인터페이스 언어" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index a692a8cee..c2189de81 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" @@ -299,7 +300,9 @@ status = "draft — needs native review" "Copy {value}" = "Copiar {value}" "Corner radius" = "Raio do canto" "Corner smoothing" = "Suavização de cantos" +"Couldn't download the variables" = "Não foi possível baixar as variáveis" "Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra." +"Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}" "Count" = "Quantidade" "Create" = "Criar" "Create a variable collection first" = "Crie primeiro uma coleção de variáveis" @@ -455,6 +458,7 @@ status = "draft — needs native review" "Fade" = "Esmaecer" "False" = "Falso" "Fetching from the server…" = "Buscando no servidor…" +"FigCraft couldn't open its window: {error}" = "O FigCraft não conseguiu abrir a janela: {error}" "File" = "Arquivo" "File name" = "Nome do arquivo" "File published" = "Arquivo publicado" @@ -566,6 +570,7 @@ status = "draft — needs native review" "Hue" = "Matiz" "Hug" = "Ajustar" "Hug contents" = "Ajustar ao conteúdo" +"If it keeps happening, please report it at {link} with this message." = "Se continuar acontecendo, informe em {link} com esta mensagem." "If" = "Se" "If {condition}" = "Se {condition}" "Ignore auto layout (absolute position)" = "Ignorar layout automático (posição absoluta)" @@ -996,6 +1001,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Remover o \"senão\" e suas ações" "Remove this condition and its actions" = "Remover esta condição e suas ações" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remover este arquivo do servidor? Ele não poderá mais ser compartilhado nem aberto a partir de lá. Sua cópia continua aberta aqui." +"Remove {name} from session" = "Remover {name} da sessão" "Remove {name} from {server}?" = "Remover {name} de {server}?" "Removed" = "Removido" "Removed from the server" = "Removido do servidor" @@ -1069,6 +1075,7 @@ status = "draft — needs native review" "Save" = "Salvar" "Save as" = "Salvar como" "Save as preset…" = "Salvar como predefinição…" +"Save files automatically" = "Salvar arquivos automaticamente" "Save version {key}" = "Salvar versão {key}" "Saved" = "Salvo" "Saved {name}" = "{name} salvo" @@ -1321,6 +1328,8 @@ status = "draft — needs native review" "Update plugin" = "Atualizar plugin" "Updated {components} and {styles}" = "{components} e {styles} atualizados" "Updated {name}" = "{name} atualizado" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Atualizar o driver de vídeo costuma ajudar. Para usar só o OpenGL, abra o FigCraft pelo Prompt de Comando com:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Atualizar o driver de vídeo costuma ajudar. WGPU_BACKEND (vulkan, gl, metal) indica outra API gráfica para tentar." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Envia este arquivo para um servidor FigCraft e mantém esta aba sincronizada com ele. Você recebe um link para editar e outro para visualizar." "Uppercase" = "Maiúsculas" "Use a fixed value" = "Usar um valor fixo" @@ -1362,6 +1371,7 @@ status = "draft — needs native review" "Visibility" = "Visibilidade" "Visible" = "Visível" "Vivid" = "Vívido" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica a API gráfica a usar; sem ela, o FigCraft escolhe sozinho." "Warm" = "Quente" "Watch" = "Relógio" "Wavy" = "Ondulado" @@ -1567,6 +1577,7 @@ status = "draft — needs native review" "collab.publish" = "Publicar no servidor" "collab.recoverFile" = "Recuperar arquivo online" "collab.registerLinks" = "Abrir links de convite no FigCraft" +"collab.removePerson" = "Remover da sessão" "collab.report" = "Denunciar arquivo ao operador do servidor" "collab.serverInfo" = "Informações do servidor" "collab.share" = "Compartilhar com um código de acesso" @@ -1783,6 +1794,7 @@ status = "draft — needs native review" "plugin.ui" = "Painel do plugin" "plugins.manage" = "Gerenciar plugins…" "plugins.runLast" = "Executar o último plugin" +"pref.autoSave" = "Salvar arquivos automaticamente" "pref.highlightHover" = "Destacar camadas ao passar o cursor" "pref.keepTool" = "Manter a ferramenta selecionada após o uso" "pref.language" = "Idioma da interface" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml new file mode 100644 index 000000000..3ab407572 --- /dev/null +++ b/crates/ui-egui/locales/ru.toml @@ -0,0 +1,2178 @@ +# FigCraft interface strings: Russian. +# Wording reviewed and approved by VanTan. +# Keep Frame, Dev Mode and Smart animate as familiar tool names. +# See docs/translating.md; plural forms follow Russian integer rules. + +[meta] +name = "Русский" + +[ui] +"% of all commits" = "% всех коммитов" +"(no longer published)" = "(публикация снята)" +"({n} warnings: some entries were skipped)" = { one = "({n} предупреждение: некоторые записи пропущены)", few = "({n} предупреждения: некоторые записи пропущены)", many = "({n} предупреждений: некоторые записи пропущены)", other = "({n} предупреждения: некоторые записи пропущены)" } +"+ Create variable" = "+ Создать переменную" +".fig (experimental)" = ".fig (экспериментально)" +"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 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 title, an agenda, a big figure, a chart and a closing slide" = "Заголовок, повестка, крупный показатель, диаграмма и заключительный слайд" +"A to Z" = "От А до Я" +"About" = "О программе" +"About FigCraft" = "О FigCraft" +"About this server" = "Об этом сервере" +"Action" = "Действие" +"Actions" = "Действия" +"Actions for {name}" = "Действия для {name}" +"Actions…" = "Действия…" +"Active" = "Активно" +"Actual size (100%)" = "Фактический размер (100%)" +"Add a category" = "Добавить категорию" +"Add a comment" = "Добавить комментарий" +"Add a description" = "Добавить описание" +"Add a frame (F) or a shape (R) to get started" = "Для начала добавьте Frame (F) или фигуру (R)" +"Add a layer or component to tile first" = "Сначала добавьте слой или компонент для повторения" +"Add a note for developers" = "Добавить заметку для разработчиков" +"Add action" = "Добавить действие" +"Add actions for when no condition holds" = "Добавить действия на случай, если ни одно условие не выполнено" +"Add an annotation for developers" = "Добавить аннотацию для разработчиков" +"Add another condition" = "Добавить ещё одно условие" +"Add auto layout" = "Добавить автолейаут" +"Add background behind overlay" = "Добавить фон за оверлеем" +"Add content to this slot" = "Добавить содержимое в этот слот" +"Add dev resource" = "Добавить ресурс для разработки" +"Add effect" = "Добавить эффект" +"Add export setting" = "Добавить настройку экспорта" +"Add fill" = "Добавить заливку" +"Add interaction" = "Добавить взаимодействие" +"Add layout grid" = "Добавить сетку макета" +"Add library" = "Добавить библиотеку" +"Add library file…" = "Добавить файл библиотеки…" +"Add max height" = "Добавить максимальную высоту" +"Add max width" = "Добавить максимальную ширину" +"Add min height" = "Добавить минимальную высоту" +"Add min width" = "Добавить минимальную ширину" +"Add mode" = "Добавить режим" +"Add new page" = "Добавить страницу" +"Add or update collections from a variables JSON" = "Добавить или обновить коллекции из JSON переменных" +"Add properties to make instances configurable" = "Добавьте свойства, чтобы настраивать экземпляры" +"Add reaction" = "Добавить реакцию" +"Add starting point" = "Добавить начальную точку" +"Add stroke" = "Добавить обводку" +"Add variant" = "Добавить вариант" +"Add variant with a new value" = "Добавить вариант с новым значением" +"Added" = "Добавлено" +"Added the library \"{name}\"" = "Добавлена библиотека «{name}»" +"Adjust" = "Коррекция" +"Adjustment presets" = "Пресеты коррекции" +"Advanced" = "Дополнительно" +"Advanced stroke" = "Расширенные настройки обводки" +"After delay" = "После задержки" +"After delay {ms}ms" = "После задержки {ms} мс" +"Align" = "Выравнивание" +"Align bottom" = "Выровнять по нижнему краю" +"Align center" = "Выровнять по центру" +"Align horizontal centers" = "Выровнять центры по горизонтали" +"Align left" = "Выровнять по левому краю" +"Align middle" = "Выровнять по середине" +"Align right" = "Выровнять по правому краю" +"Align text baselines" = "Выровнять базовые линии текста" +"Align top" = "Выровнять по верхнему краю" +"Align vertical centers" = "Выровнять центры по вертикали" +"Alignment" = "Выравнивание" +"All" = "Все" +"All categories" = "Все категории" +"All comments" = "Все комментарии" +"All components" = "Все компоненты" +"All fonts" = "Все шрифты" +"All supported properties" = "Все поддерживаемые свойства" +"All variables" = "Все переменные" +"Alpha" = "Альфа" +"Alphabetical" = "По алфавиту" +"Alt+drag" = "Alt+перетаскивание" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Экран аналитики на сетке: показатели, векторные диаграммы и таблица" +"An invite link that opens FigCraft and joins" = "Ссылка приглашения, которая открывает FigCraft и подключает к сессии" +"Anchor" = "Привязка" +"Android" = "Android" +"Angular" = "Угловой" +"Animation" = "Анимация" +"Annotations" = "Аннотации" +"Annotations shown on the canvas" = "Аннотации на холсте" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Другой сервер или сервер, требующий ключ. Основной сервер задаётся в настройках." +"Any status" = "Любой статус" +"Anyone on your network with this code can edit the file, while this app stays open." = "Любой в вашей сети с этим кодом может редактировать файл, пока приложение открыто." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "По ссылке для редактирования можно изменять файл, по ссылке для просмотра — только смотреть. Ссылки содержат секреты: передавайте их как пароли. `figcraft-server links` создаёт новые ссылки и отзывает старые." +"Anyone with this code can edit the file." = "Любой с этим кодом может редактировать файл." +"Anyone with this code can look at the file, not change it." = "Любой с этим кодом может просматривать файл без возможности редактирования." +"Appearance" = "Внешний вид" +"Apply a component property" = "Применить свойство компонента" +"Apply variable" = "Применить переменную" +"Apply variable to text" = "Применить переменную к тексту" +"Apply variable to visibility" = "Применить переменную к видимости" +"Apps" = "Приложения" +"Apr" = "апр." +"Arc" = "Дуга" +"Arc ratio" = "Соотношение дуги" +"Arc start" = "Начало дуги" +"Arc sweep" = "Угол дуги" +"Archive" = "Архивировать" +"Archived" = "В архиве" +"Arrange" = "Упорядочить" +"Arrow" = "Стрелка" +"As typed" = "Как введено" +"Asking the server…" = "Запрос к серверу…" +"Assets" = "Ресурсы" +"Aug" = "авг." +"Auto" = "Авто" +"Auto (space around)" = "Авто (отступы вокруг)" +"Auto (space between)" = "Авто (отступы между)" +"Auto (space evenly)" = "Авто (равные отступы)" +"Auto ({mode})" = "Авто ({mode})" +"Auto columns" = "Автостолбцы" +"Auto gap" = "Автоинтервал" +"Auto height" = "Автовысота" +"Auto layout" = "Автолейаут" +"Auto rows" = "Автостроки" +"Auto width" = "Автоширина" +"Back to the FigCraft server" = "Вернуться к серверу FigCraft" +"Back to the font's value" = "Вернуть значение шрифта" +"Back" = "Назад" +"Back to Ready for dev" = "Снова готово к разработке" +"Back to Ready for dev: the next change compares with how it is now" = "Снова готово к разработке: следующие изменения сравниваются с текущим состоянием" +"Back to assets" = "Назад к ресурсам" +"Back to current" = "Вернуться к текущему" +"Back to styles" = "Назад к стилям" +"Back to the canvas from a focused region" = "Вернуться из выбранной области к холсту" +"Back to the current version" = "Вернуться к текущей версии" +"Back to the values the font style implies" = "Вернуть значения стиля шрифта" +"Background" = "Фон" +"Background blur" = "Размытие фона" +"Basics" = "Основы" +"Before" = "До" +"Bend" = "Изгиб" +"Bevel" = "Скошенное" +"Binary assets added" = "Добавлено бинарных ресурсов" +"Binary assets removed" = "Удалено бинарных ресурсов" +"Black & white" = "Чёрно-белое" +"Bleed" = "Вылет за обрез" +"Bleed marks" = "Метки вылета" +"Bleed marks need a bleed" = "Для меток вылета нужно задать вылет" +"Blend" = "Смешивание" +"Blend mode" = "Режим наложения" +"Blue" = "Синий" +"Blur" = "Размытие" +"Boolean" = "Логическое" +"Boolean groups" = "Булевы группы" +"Both directions" = "Оба направления" +"Bottom" = "Снизу" +"Bottom left radius" = "Радиус нижнего левого угла" +"Bottom right radius" = "Радиус нижнего правого угла" +"Branch" = "Ветка" +"Branch archived" = "Ветка архивирована" +"Branch merged" = "Ветка объединена" +"Branch name" = "Имя ветки" +"Branch of {file}" = "Ветка файла {file}" +"Branch of {file} · merged or archived" = "Ветка файла {file} · объединена или архивирована" +"Branch updated from main" = "Ветка обновлена из основной" +"Branch {name}" = "Ветка {name}" +"Branches" = "Ветки" +"Bound to {name}. Click to detach the variable." = "Привязано к {name}. Нажмите, чтобы отвязать переменную." +"Bring forward" = "На уровень вперёд" +"Bring the main file's changes into the branch" = "Перенести изменения основного файла в ветку" +"Bring to front" = "На передний план" +"Bristle" = "Щетина" +"Browse" = "Обзор" +"Brush" = "Кисть" +"Brush rotation jitter" = "Колебание угла кисти" +"Brush scatter" = "Рассеивание кисти" +"Brush size jitter" = "Колебание размера кисти" +"Brush spacing" = "Интервал кисти" +"Bulleted list" = "Маркированный список" +"C or Shift+C" = "C или Shift+C" +"C or ⇧C" = "C или ⇧C" +"CMYK colour" = "Цвет CMYK" +"Can view" = "Может просматривать" +"Checking the server…" = "Проверка сервера…" +"Clear from this list" = "Убрать из этого списка" +"Code or invite link" = "Код или ссылка приглашения" +"Color profile: {name}" = "Цветовой профиль: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Цветовые и текстовые стили, переменные светлой и тёмной тем, кнопки, поля ввода и карточки" +"CSS custom properties…" = "Пользовательские свойства CSS…" +"Can edit" = "Может редактировать" +"Can't read this library: {e}" = "Не удалось прочитать библиотеку: {e}" +"Cancel" = "Отмена" +"Canvas" = "Холст" +"Canvas stacking" = "Порядок наложения на холсте" +"Cap height to baseline" = "От высоты прописных до базовой линии" +"Case" = "Регистр" +"Case-sensitive forms" = "Формы с учётом регистра" +"Categories" = "Категории" +"Center" = "По центру" +"Change alias" = "Изменить алиас" +"Change resolved" = "Изменение разрешено" +"Change style" = "Изменить стиль" +"Change to" = "Переключить на" +"Change to needs a layer inside a variant" = "Для переключения нужен слой внутри варианта" +"Changed" = "Изменено" +"Changed on both sides: keep one" = "Изменено с обеих сторон: выберите один вариант" +"Changes on main" = "Изменения в основной ветке" +"Changes on the branch" = "Изменения в ветке" +"Changes unknown" = "Изменения неизвестны" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Выберите выше React, Leptos, SwiftUI или Compose, чтобы связать компонент с его кодом." +"Choose another profile to change it." = "Для смены выберите другой профиль." +"Choose collection" = "Выбрать коллекцию" +"Choose image" = "Выбрать изображение" +"Choose mode" = "Выбрать режим" +"Choose variable" = "Выбрать переменную" +"Circle" = "Круг" +"Clear recent" = "Очистить недавние" +"Click + to replace mixed content" = "Нажмите +, чтобы заменить смешанное содержимое" +"Click anywhere on the canvas to leave one." = "Нажмите в любом месте холста, чтобы оставить комментарий." +"Click or drag to merge · {alt} removes" = "Нажмите или перетащите для объединения · {alt} удаляет" +"Click or drag to remove · {alt} merges" = "Нажмите или перетащите для удаления · {alt} объединяет" +"Click to record a key" = "Нажмите для записи клавиши" +"Clip content" = "Обрезать содержимое" +"Clip to shape" = "Обрезать по фигуре" +"Close" = "Закрыть" +"Close overlay" = "Закрыть оверлей" +"Close preview" = "Закрыть предпросмотр" +"Close tab" = "Закрыть вкладку" +"Close version history" = "Закрыть историю версий" +"Close when clicking outside" = "Закрывать при нажатии снаружи" +"Close without saving" = "Закрыть без сохранения" +"Code" = "Код" +"Code syntax" = "Синтаксис кода" +"Collapse" = "Свернуть" +"Collapse panel" = "Свернуть панель" +"Collection" = "Коллекция" +"Collections" = "Коллекции" +"Color" = "Цвет" +"Color burn" = "Затемнение основы" +"Color dodge" = "Осветление основы" +"Color format" = "Формат цвета" +"Color profile" = "Цветовой профиль" +"Color styles" = "Цветовые стили" +"Color values in this file are in this color space." = "Значения цветов в этом файле заданы в этом цветовом пространстве." +"Color variables" = "Цветовые переменные" +"Colour" = "Цвет" +"Column" = "Столбец" +"Column gap" = "Интервал между столбцами" +"Column sizes" = "Размеры столбцов" +"Column span" = "Объединение столбцов" +"Columns" = "Столбцы" +"Columns {count}" = "Столбцы: {count}" +"Comment" = "Комментарий" +"Comments" = "Комментарии" +"Commits" = "Коммиты" +"Company" = "Компания" +"Compare changes" = "Сравнить изменения" +"Completed" = "Завершено" +"Component" = "Компонент" +"Component name" = "Имя компонента" +"Component property" = "Свойство компонента" +"Component set" = "Набор компонентов" +"Component sets" = "Наборы компонентов" +"Components" = "Компоненты" +"Conditional" = "Условие" +"Confetti" = "Конфетти" +"Conflicts" = "Конфликты" +"Connect to code" = "Связать с кодом" +"Connected" = "Подключено" +"Connected to code: {from}" = "Связано с кодом: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Соединение потеряно, переподключение (попытка {attempt})…" +"Constrain proportions" = "Сохранять пропорции" +"Constraints" = "Ограничения" +"Content" = "Содержимое" +"Contents hidden" = "Содержимое скрыто" +"Contents only" = "Только содержимое" +"Contextual alternates" = "Контекстные альтернативы" +"Contrast" = "Контраст" +"Contributors" = "Участники" +"Contributors: {people} · Commits: {commits}" = "Участники: {people} · Коммиты: {commits}" +"Controller" = "Контроллер" +"Cool" = "Холодный" +"Copied as CSS" = "Скопировано как CSS" +"Copied as PNG" = "Скопировано как PNG" +"Copied as SVG" = "Скопировано как SVG" +"Copied as text" = "Скопировано как текст" +"Copied {value}" = "Скопировано: {value}" +"Copy" = "Копировать" +"Copy as" = "Копировать как" +"Copy as CSS" = "Копировать как CSS" +"Copy as PNG" = "Копировать как PNG" +"Copy as SVG" = "Копировать как SVG" +"Copy code" = "Копировать код" +"Copy link" = "Копировать ссылку" +"Copy the {label}" = "Копировать: {label}" +"Copy {value}" = "Копировать {value}" +"Corner radius" = "Радиус углов" +"Corner smoothing" = "Сглаживание углов" +"Couldn't reach {server}: showing what this computer remembers." = "Не удалось связаться с {server}: показаны данные, сохранённые на этом компьютере." +"Count" = "Количество" +"Create" = "Создать" +"Create a variable collection first" = "Сначала создайте коллекцию переменных" +"Create a variable first" = "Сначала создайте переменную" +"Create alias" = "Создать алиас" +"Create branch" = "Создать ветку" +"Create collection" = "Создать коллекцию" +"Create component" = "Создать компонент" +"Create component property" = "Создать свойство компонента" +"Create components, styles or variables to publish them as a library." = "Создайте компоненты, стили или переменные, чтобы опубликовать их как библиотеку." +"Create property…" = "Создать свойство…" +"Create style" = "Создать стиль" +"Create style from the selected text" = "Создать стиль из выделенного текста" +"Created" = "Создано" +"Crop" = "Обрезка" +"Crop marks" = "Метки обреза" +"Ctrl+arrows" = "Ctrl+стрелки" +"Ctrl+click" = "Ctrl+нажатие" +"Ctrl+scroll" = "Ctrl+прокрутка" +"Current name" = "Текущее имя" +"Current version" = "Текущая версия" +"Cursor chat" = "Чат у курсора" +"Custom" = "Пользовательский" +"Cut" = "Вырезать" +"Dark" = "Тёмная" +"Darken" = "Затемнение" +"Dash" = "Штрих" +"Dashboard" = "Панель аналитики" +"Date created" = "Дата создания" +"Dec" = "дек." +"Decoration style" = "Стиль оформления" +"Decoration" = "Оформление" +"Deep select" = "Глубокое выделение" +"Default" = "По умолчанию" +"Default content" = "Содержимое по умолчанию" +"Default value (the top-left variant)" = "Значение по умолчанию (верхний левый вариант)" +"Delay" = "Задержка" +"Delete" = "Удалить" +"Delete category (its annotations stay)" = "Удалить категорию (аннотации сохранятся)" +"Delete collection" = "Удалить коллекцию" +"Delete group" = "Удалить группу" +"Delete mode" = "Удалить режим" +"Delete page" = "Удалить страницу" +"Delete preset" = "Удалить пресет" +"Delete property" = "Удалить свойство" +"Delete the style (layers keep their values)" = "Удалить стиль (значения слоёв сохранятся)" +"Delete thread" = "Удалить обсуждение" +"Delete variable" = "Удалить переменную" +"Delete “{name}”? This can't be undone." = "Удалить «{name}»? Это действие нельзя отменить." +"Density" = "Плотность" +"Depth" = "Глубина" +"Describe how and when to use this component" = "Опишите, как и когда использовать этот компонент" +"Describe what changed" = "Опишите изменения" +"Description" = "Описание" +"Description (optional)" = "Описание (необязательно)" +"Design" = "Дизайн" +"Design system starter" = "Основа дизайн-системы" +"Desktop" = "Компьютер" +"Destination" = "Назначение" +"Detach alias" = "Отвязать алиас" +"Detach instance" = "Отсоединить экземпляр" +"Detach property" = "Отвязать свойство" +"Detach style" = "Отвязать стиль" +"Detach styles" = "Отвязать стили" +"Detach variable" = "Отвязать переменную" +"Details" = "Подробности" +"Dev Mode" = "Dev Mode" +"Dev resources" = "Ресурсы для разработки" +"Development" = "Разработка" +"Device" = "Устройство" +"Device at 100%" = "Устройство в масштабе 100%" +"Diamond" = "Ромб" +"Difference" = "Разница" +"Dimensions" = "Размеры" +"Direction" = "Направление" +"Disconnect from code" = "Отвязать от кода" +"Dispersion" = "Дисперсия" +"Display name" = "Отображаемое имя" +"Dissolve" = "Растворение" +"Distribute and tidy" = "Распределить и упорядочить" +"Distribute horizontal spacing" = "Распределить по горизонтали" +"Distribute vertical spacing" = "Распределить по вертикали" +"Divider" = "Разделитель" +"Don't truncate" = "Не обрезать" +"Done" = "Готово" +"Done editing" = "Завершить редактирование" +"Dots" = "Точки" +"Dotted" = "Точечный" +"Down" = "Вниз" +"Drafts" = "Черновики" +"Drag the stroke to add a width point" = "Перетащите обводку, чтобы добавить точку ширины" +"Draw" = "Рисование" +"Draw a frame" = "Нарисовать Frame" +"Drawing tools" = "Инструменты рисования" +"Drop shadow" = "Внешняя тень" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Двойная лицензия MIT OR Apache-2.0. Создано командой и сообществом ArtCraft." +"Duo" = "Дуо" +"Duplicate" = "Дублировать" +"Duplicate by dragging" = "Дублировать перетаскиванием" +"Duplicate mode" = "Дублировать режим" +"Duplicate page" = "Дублировать страницу" +"Edit" = "Правка" +"Edit as an expression" = "Редактировать как выражение" +"Edit colour" = "Изменить цвет" +"Edit comment" = "Изменить комментарий" +"Edit dev resource" = "Изменить ресурс для разработки" +"Edit link" = "Ссылка для редактирования" +"Edit link copied" = "Ссылка для редактирования скопирована" +"Edit style" = "Изменить стиль" +"Edit variable" = "Изменить переменную" +"Edit with the builder" = "Редактировать в конструкторе" +"Edited {when}" = "Изменено {when}" +"Effect settings" = "Настройки эффекта" +"Effect styles" = "Стили эффектов" +"Effects" = "Эффекты" +"Ellipse" = "Эллипс" +"Ellipses" = "Эллипсы" +"Else" = "Иначе" +"Else if" = "Иначе если" +"Embedded profile" = "Встроенный профиль" +"Empty" = "Пусто" +"End" = "Конец" +"End point" = "Конечная точка" +"Ends the session; everyone keeps their copy" = "Завершает сессию; у всех остаются свои копии" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Введите адрес figcraft-server и нажмите «Обзор», чтобы увидеть доступные плагины." +"Enter to save · Esc to cancel" = "Enter — сохранить · Esc — отменить" +"Eraser" = "Ластик" +"Esc" = "Esc" +"Essential" = "Основные" +"Every conflict has a choice." = "Для каждого конфликта выбран вариант." +"Every top-level frame already starts a flow" = "Каждый Frame верхнего уровня уже начинает сценарий" +"Exact opacity (4 then 5: 45%)" = "Точная непрозрачность (4, затем 5: 45%)" +"Exclude" = "Исключение" +"Excluded" = "Исключено" +"Excluded from layout" = "Исключено из компоновки" +"Excludes" = "Исключения" +"Exclusion" = "Исключение" +"Exit focus view (Esc)" = "Выйти из режима фокуса (Esc)" +"Expand" = "Развернуть" +"Export" = "Экспорт" +"Export all" = "Экспортировать всё" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Экспортировать Frame и слои в PNG, JPG, SVG или PDF" +"Export page as PNG" = "Экспортировать страницу в PNG" +"Export settings" = "Настройки экспорта" +"Export variables" = "Экспортировать переменные" +"Export {name}" = "Экспортировать {name}" +"Export {n} layers" = { one = "Экспортировать {n} слой", few = "Экспортировать {n} слоя", many = "Экспортировать {n} слоёв", other = "Экспортировать {n} слоя" } +"Exported {file}" = "Экспортирован файл {file}" +"Exported {name}" = "Экспортировано: {name}" +"Exported {name}; {n} kinds of content were left out" = { one = "Экспортировано: {name}; пропущен {n} тип содержимого", few = "Экспортировано: {name}; пропущены {n} типа содержимого", many = "Экспортировано: {name}; пропущено {n} типов содержимого", other = "Экспортировано: {name}; пропущено {n} типа содержимого" } +"Exported {n} files" = { one = "Экспортирован {n} файл", few = "Экспортированы {n} файла", many = "Экспортировано {n} файлов", other = "Экспортировано {n} файла" } +"Export…" = "Экспорт…" +"Expose properties to instances" = "Сделать свойства доступными в экземплярах" +"Exposure" = "Экспозиция" +"Expression" = "Выражение" +"Extend collection" = "Расширить коллекцию" +"Extends \"{name}\": overridden values are marked" = "Расширяет «{name}»: переопределённые значения отмечены" +"Eye" = "Глаз" +"F" = "F" +"Fade" = "Затухание" +"False" = "Ложь" +"Feb" = "февр." +"Fetching from the server…" = "Загрузка с сервера…" +"File" = "Файл" +"File name" = "Имя файла" +"File not found" = "Файл не найден" +"File published" = "Файл опубликован" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Файлы не защищены сквозным шифрованием: оператор сервера может их прочитать." +"Files nobody opens for {n} days are deleted." = { one = "Файлы, которые никто не открывает {n} день, удаляются.", few = "Файлы, которые никто не открывает {n} дня, удаляются.", many = "Файлы, которые никто не открывает {n} дней, удаляются.", other = "Файлы, которые никто не открывает {n} дня, удаляются." } +"Fill" = "Заливка" +"Fill (fr)" = "Заполнение (fr)" +"Fill container" = "Заполнить контейнер" +"Fill window" = "Заполнить окно" +"Fills" = "Заливки" +"Fill|sizing" = "Заполнение" +"Filter and sort" = "Фильтрация и сортировка" +"Find" = "Найти" +"Find and replace" = "Найти и заменить" +"Find layers" = "Найти слои" +"First" = "Первый" +"First commit" = "Первый коммит" +"First on top" = "Первый сверху" +"Fit" = "Вписать" +"Fit device to window" = "Вписать устройство в окно" +"Fit to contents" = "По содержимому" +"Fit to window" = "Вписать в окно" +"Fit width" = "По ширине" +"Fixed" = "Фиксированный" +"Fixed gap" = "Фиксированный интервал" +"Fixed size" = "Фиксированный размер" +"Flatten" = "Свести" +"Flip" = "Отразить" +"Flip horizontal" = "Отразить по горизонтали" +"Flip text to the other side" = "Перенести текст на другую сторону" +"Flip vertical" = "Отразить по вертикали" +"Flip width profile" = "Отразить профиль ширины" +"Flow starting point" = "Начальная точка сценария" +"Flow starting points" = "Начальные точки сценариев" +"Flows" = "Сценарии" +"Focus view" = "Режим фокуса" +"Fold autosaves" = "Свернуть автосохранения" +"Folder: click to collapse" = "Папка: нажмите, чтобы свернуть" +"Folder: click to expand" = "Папка: нажмите, чтобы развернуть" +"Follow" = "Следить" +"Follow {name}" = "Следить за {name}" +"Following" = "Следование" +"Following {name} · {key} to stop" = "Следование за {name} · {key} — остановить" +"Font" = "Шрифт" +"Font not installed" = "Шрифт не установлен" +"Font size" = "Размер шрифта" +"Font style" = "Начертание шрифта" +"Forget on this computer" = "Забыть на этом компьютере" +"Four screens of a trail app with components and a prototype flow" = "Четыре экрана приложения для походов с компонентами и сценарием прототипа" +"Fractions" = "Дроби" +"Frame" = "Frame" +"Frame selection" = "Поместить выделение во Frame" +"Frame tools" = "Инструменты Frame" +"Frames" = "Frames" +"From the library \"{name}\": read-only" = "Из библиотеки «{name}»: только чтение" +"From {name}" = "Из {name}" +"From {server}" = "С {server}" +"Frost" = "Иней" +"Gap" = "Интервал" +"Gap between columns" = "Интервал между столбцами" +"Gap between items" = "Интервал между элементами" +"Gap between rows" = "Интервал между строками" +"Getting started" = "Начало работы" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Укажите адрес общедоступного сервера или адрес сервера с ключом (`figcraft-server key` выводит ключ)." +"Glass" = "Стекло" +"Go to main component" = "Перейти к основному компоненту" +"Go to {name}" = "Перейти к {name}" +"Grab bag" = "Список имён" +"Gradient" = "Градиент" +"Gray" = "Серый" +"Green" = "Зелёный" +"Greyscale components and two example screens to sketch a flow with" = "Монохромные компоненты и два примера экранов для наброска сценария" +"Grid" = "Сетка" +"Grid ({size})" = "Сетка ({size})" +"Grid styles" = "Стили сеток" +"Grid {size}" = "Сетка {size}" +"Group" = "Группа" +"Group selection" = "Сгруппировать выделение" +"Groups" = "Группы" +"Gutter" = "Межколонник" +"Hand tool" = "Инструмент «Рука»" +"Hanging list" = "Выступающий список" +"Hanging punctuation" = "Висячая пунктуация" +"Hard light" = "Жёсткий свет" +"Height" = "Высота" +"Help" = "Справка" +"Help and resources" = "Справка и ресурсы" +"Help page" = "Страница справки" +"Here: {names}" = "Здесь: {names}" +"Hidden" = "Скрыто" +"Hide contents" = "Скрыть содержимое" +"Hide from publishing" = "Исключить из публикации" +"Hide section contents" = "Скрыть содержимое секции" +"Hide these tips (Getting started in the Help menu shows them again)" = "Скрыть подсказки (их можно вернуть через «Начало работы» в меню справки)" +"Hide when publishing" = "Скрывать при публикации" +"Highlight layers on hover" = "Подсвечивать слои при наведении" +"Highlights" = "Света" +"Hold Alt" = "Удерживайте Alt" +"Hold Alt+Z" = "Удерживайте Alt+Z" +"Hold Ctrl" = "Удерживайте Ctrl" +"Hold Z" = "Удерживайте Z" +"Hold ⌃ or ⌘" = "Удерживайте ⌃ или ⌘" +"Hold ⌥" = "Удерживайте ⌥" +"Hold ⌥Z" = "Удерживайте ⌥Z" +"Home" = "Главная" +"Horizontal" = "Горизонтально" +"Horizontal align" = "Горизонтальное выравнивание" +"Horizontal hex" = "Горизонтальный шестиугольник" +"Horizontal padding" = "Горизонтальные внутренние отступы" +"Horizontal spacing" = "Горизонтальный интервал" +"Hosted by {operator}" = "Оператор: {operator}" +"Hover a layer to see its properties" = "Наведите на слой, чтобы увидеть его свойства" +"Hover other layers to measure the distance" = "Наведите на другие слои, чтобы измерить расстояние" +"Hue" = "Цветовой тон" +"Hug" = "По содержимому" +"Hug contents" = "По размеру содержимого" +"Icon components on a 24 px grid with keylines and a size preview" = "Компоненты иконок на сетке 24 px с направляющими и предпросмотром размеров" +"Icon set starter" = "Основа набора иконок" +"If" = "Если" +"If {condition}" = "Если {condition}" +"Ignore auto layout (absolute position)" = "Игнорировать автолейаут (абсолютная позиция)" +"Image" = "Изображение" +"Import" = "Импорт" +"Import path" = "Путь импорта" +"Import plugin from manifest" = "Импортировать плагин из манифеста" +"Import plugin from manifest…" = "Импортировать плагин из манифеста…" +"Import variables" = "Импортировать переменные" +"Imported {name}" = "Импортировано: {name}" +"Imported {n} new variables, updated {updated}" = { one = "Импортирована {n} новая переменная, обновлено: {updated}", few = "Импортированы {n} новые переменные, обновлено: {updated}", many = "Импортировано {n} новых переменных, обновлено: {updated}", other = "Импортировано {n} новой переменной, обновлено: {updated}" } +"Import…" = "Импорт…" +"In an instance, this comes from the main component" = "В экземпляре это значение берётся из основного компонента" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "В браузере можно подключаться к сессиям настольного приложения и файлам на сервере FigCraft. Страница, открытая по https, подключается к другим компьютерам только по ссылкам wss:// (сервер с TLS)." +"Include bounding box" = "Включить ограничивающий прямоугольник" +"Include id attribute" = "Включить атрибут id" +"Included" = "Включено" +"Included in layout" = "Участвует в компоновке" +"Individual corners" = "Углы по отдельности" +"Individual strokes" = "Обводки по отдельности" +"Ink" = "Чернила" +"Inner shadow" = "Внутренняя тень" +"Inside" = "Внутри" +"Inspect" = "Инспектор" +"Inspect the layer under the pointer: size, colours, typography" = "Свойства слоя под указателем: размер, цвета, типографика" +"Install plugin" = "Установить плагин" +"Install" = "Установить" +"Installed {name}" = "Установлено: {name}" +"Installed" = "Установлено" +"Installing…" = "Установка…" +"Instance" = "Экземпляр" +"Instance properties" = "Свойства экземпляра" +"Instance swap" = "Замена экземпляра" +"Instance swap (no other component)" = "Замена экземпляра (других компонентов нет)" +"Instances" = "Экземпляры" +"Instant" = "Мгновенно" +"Interaction" = "Взаимодействие" +"Interactions" = "Взаимодействия" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Дизайн интерфейсов и прототипирование. Бесплатный инструмент с открытым исходным кодом, независимо созданный с нуля на Rust, из семейства ArtCraft Crafting Apps." +"Intersect" = "Пересечение" +"Intersects" = "Пересечения" +"Invite link" = "Ссылка приглашения" +"Invite link copied" = "Ссылка приглашения скопирована" +"It asks permission to:" = "Запрашивает разрешение на:" +"Jan" = "янв." +"Join" = "Подключиться" +"Join code copied" = "Код подключения скопирован" +"Join code or invite link" = "Код подключения или ссылка приглашения" +"Join live session" = "Подключиться к совместной сессии" +"Join us on Discord" = "Присоединиться к нам в Discord" +"Join with a code" = "Подключиться по коду" +"Joined" = "Подключено" +"Joined {file}" = "Подключено к {file}" +"Joining…" = "Подключение…" +"Join|stroke" = "Соединение" +"Jul" = "июл." +"Jun" = "июн." +"Just now" = "Только что" +"Justify" = "По ширине" +"Keep appearance" = "Сохранить внешний вид" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "«Сохранить внешний вид» преобразует все значения цветов в {profile}, сохраняя их вид. «Сохранить значения» оставляет числа без изменений, поэтому цвета меняются." +"Keep tool selected after use" = "Оставлять инструмент выбранным после использования" +"Keep values" = "Сохранить значения" +"Key/Gamepad" = "Клавиша/геймпад" +"Key/Gamepad {key}" = "Клавиша/геймпад {key}" +"Keyboard shortcuts" = "Сочетания клавиш" +"Kits" = "Наборы" +"Landing page" = "Лендинг" +"Language" = "Язык" +"Lasso" = "Лассо" +"Last" = "Последний" +"Last change: {note}" = "Последнее изменение: {note}" +"Last commit" = "Последний коммит" +"Last modified" = "Последнее изменение" +"Last on top" = "Последний сверху" +"Layer blur" = "Размытие слоя" +"Layer name" = "Имя слоя" +"Layers" = "Слои" +"Layers in an instance take their position from the main component" = "Положение слоёв экземпляра задаётся основным компонентом" +"Layout" = "Компоновка" +"Layout grid" = "Сетка макета" +"Layout grid settings" = "Настройки сетки макета" +"Layout grids" = "Сетки макета" +"Leave session" = "Покинуть сессию" +"Leaves" = "Листья" +"Left" = "Слева" +"Left & right" = "Слева и справа" +"Left side" = "Левая сторона" +"Left side width" = "Ширина левой стороны" +"Letter case" = "Регистр букв" +"Letter spacing" = "Межбуквенный интервал" +"Libraries" = "Библиотеки" +"Libraries in this file" = "Библиотеки в этом файле" +"Library updates available" = "Доступны обновления библиотек" +"Library: click to collapse" = "Библиотека: нажмите, чтобы свернуть" +"Library: click to expand" = "Библиотека: нажмите, чтобы развернуть" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Библиотека: здесь переменные доступны только для чтения; измените их в файле библиотеки и опубликуйте его" +"Light" = "Светлая" +"Lighten" = "Осветление" +"Light|glass" = "Свет" +"Line" = "Линия" +"Line arrow" = "Линейная стрелка" +"Line delta" = "Разница строк" +"Line height" = "Межстрочный интервал" +"Linear" = "Линейный" +"Lines" = "Линии" +"Lines +/−" = "Строки +/−" +"Lines added" = "Добавлено строк" +"Lines deleted" = "Удалено строк" +"Link" = "Ссылка" +"Link copied" = "Ссылка скопирована" +"Link docs, code or tickets for developers." = "Добавьте ссылки на документацию, код или задачи для разработчиков." +"Link frames on the Prototype tab, then present the flow" = "Свяжите Frame на вкладке «Прототип», затем запустите сценарий" +"Link this component to its code component: instances then show your real snippet with their values." = "Свяжите этот компонент с компонентом в коде: экземпляры будут показывать ваш код со своими значениями." +"List spacing" = "Интервал списка" +"List style" = "Стиль списка" +"Live session" = "Совместная сессия" +"Live · {people}" = "Онлайн · {people}" +"Local components" = "Локальные компоненты" +"Local network" = "Локальная сеть" +"Local styles" = "Локальные стили" +"Local variables" = "Локальные переменные" +"Lock/Unlock" = "Заблокировать/разблокировать" +"Locked" = "Заблокировано" +"Lowercase" = "Строчные" +"Luminance" = "Яркость" +"Luminosity" = "Свечение" +"Main" = "Основная" +"Main component deleted" = "Основной компонент удалён" +"Main menu" = "Главное меню" +"Make a component" = "Создать компонент" +"Make slot" = "Сделать слотом" +"Mar" = "мар." +"Margin" = "Внешний отступ" +"Mark all as read" = "Отметить всё как прочитанное" +"Mark as resolved" = "Отметить как решённое" +"Mark as unread" = "Отметить как непрочитанное" +"Mark as unresolved" = "Отметить как нерешённое" +"Mask" = "Маска" +"Mass · Stiffness · Damping" = "Масса · Жёсткость · Демпфирование" +"Match (empty: the whole name)" = "Совпадение (пусто: всё имя)" +"Max H" = "Макс. В" +"Max W" = "Макс. Ш" +"Max lines" = "Максимум строк" +"Maximize" = "Развернуть окно" +"May" = "май" +"Measure to the selection" = "Измерить до выделения" +"Merge into main" = "Объединить с основной" +"Merge regions" = "Объединить области" +"Merge the branch into the main file (one undo step) and archive it" = "Объединить ветку с основным файлом (один шаг отмены) и архивировать её" +"Merged" = "Объединено" +"Merged PRs" = "Объединённые PR" +"Min H" = "Мин. В" +"Min W" = "Мин. Ш" +"Minimize" = "Свернуть окно" +"Mirror angle" = "Отражать угол" +"Mirror angle and length" = "Отражать угол и длину" +"Mirroring" = "Зеркальность" +"Missing" = "Отсутствует" +"Missing layer" = "Слой отсутствует" +"Missing style" = "Стиль отсутствует" +"Missing variable" = "Переменная отсутствует" +"Miter" = "Острое" +"Miter angle" = "Угол острого соединения" +"Mixed" = "Смешанное" +"Mixed fonts" = "Разные шрифты" +"Mixed interactions: click + to replace them" = "Разные взаимодействия: нажмите + для замены" +"Mobile app" = "Мобильное приложение" +"Mode" = "Режим" +"Model" = "Модель" +"Models" = "Модели" +"Mono" = "Моно" +"More" = "Ещё" +"More instance actions" = "Другие действия с экземпляром" +"Mouse down" = "Нажатие кнопки мыши" +"Mouse enter" = "Вход указателя" +"Mouse leave" = "Выход указателя" +"Mouse up" = "Отпускание кнопки мыши" +"Move along the toolbar or the layer rows" = "Перемещение по панели инструментов или строкам слоёв" +"Move" = "Перемещение" +"Move column left" = "Переместить столбец влево" +"Move column right" = "Переместить столбец вправо" +"Move down" = "Переместить вниз" +"Move in" = "Сдвиг внутрь" +"Move out" = "Сдвиг наружу" +"Move tools" = "Инструменты перемещения" +"Move up" = "Переместить вверх" +"Moved to {name}" = "Перемещено в {name}" +"Multi" = "Мульти" +"Multiply" = "Умножение" +"Muted" = "Приглушённый" +"Name" = "Имя" +"Name (A–Z)" = "Имя (А–Я)" +"Named instance" = "Именованный экземпляр" +"Navigate to" = "Перейти к" +"Nested instance" = "Вложенный экземпляр" +"New design file" = "Новый файл дизайна" +"Newest first" = "Сначала новые" +"Next or previous control in the focused region" = "Следующий или предыдущий элемент в активной области" +"Next ready for dev" = "Следующий готовый к разработке" +"Next size option" = "Следующий вариант размера" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Нет ресурсов: настроек экспорта, иконок или изображений. Добавьте настройки экспорта на вкладке «Дизайн»." +"No boolean variables fit here." = "Нет подходящих логических переменных." +"No boolean variables yet. Create them in Local variables." = "Логических переменных пока нет. Создайте их в разделе «Локальные переменные»." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Веток нет. Ветка — копия файла, в которой можно попробовать изменения, затем проверить и объединить их." +"No category" = "Без категории" +"No changes" = "Нет изменений" +"No colour variables fit here." = "Нет подходящих цветовых переменных." +"No colour variables yet. Create them in Local variables." = "Цветовых переменных пока нет. Создайте их в разделе «Локальные переменные»." +"No comments yet" = "Комментариев пока нет" +"No components yet. Select layers and press {keys}." = "Компонентов пока нет. Выделите слои и нажмите {keys}." +"No conflicts." = "Конфликтов нет." +"No contributor data was built into this copy." = "В этой сборке нет данных об участниках." +"No fonts match" = "Подходящих шрифтов нет" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Код подключения недоступен (сервер не поддерживает коды или адрес нельзя включить в код): поделитесь ссылкой." +"No layer changes to show." = "Нет изменений слоёв для показа." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Библиотек пока нет. Добавьте файл .figcraft, опубликованный как библиотека, или вставьте ссылку на командную библиотеку." +"No list" = "Без списка" +"No matching comments" = "Подходящих комментариев нет" +"No matching components" = "Подходящих компонентов нет" +"No matching layers" = "Подходящих слоёв нет" +"No matching styles" = "Подходящих стилей нет" +"No matching variables" = "Подходящих переменных нет" +"No mirroring" = "Без зеркальности" +"No model credits were built into this copy." = "В этой сборке нет сведений об использованных моделях." +"No number variables fit here." = "Нет подходящих числовых переменных." +"No number variables yet. Create them in Local variables." = "Числовых переменных пока нет. Создайте их в разделе «Локальные переменные»." +"No paints" = "Заливок нет" +"No plugins on {server}." = "На {server} нет плагинов." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Плагинов пока нет. Импортируйте плагин для разработки из его figcraft-plugin.json." +"No preferred components" = "Предпочтительных компонентов нет" +"No preset" = "Без пресета" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Сохранённых версий пока нет. Сохраните версию, чтобы можно было вернуться к этому этапу работы." +"No scrolling" = "Без прокрутки" +"No shortcuts match" = "Подходящих сочетаний нет" +"No status" = "Без статуса" +"No string variables fit here." = "Нет подходящих строковых переменных." +"No string variables yet. Create them in Local variables." = "Строковых переменных пока нет. Создайте их в разделе «Локальные переменные»." +"No styles yet." = "Стилей пока нет." +"No styles yet. Click + to create one from the selection." = "Стилей пока нет. Нажмите +, чтобы создать стиль из выделения." +"No templates match your search" = "По вашему запросу нет шаблонов" +"No text styles yet. Click + to create one from the selected text." = "Текстовых стилей пока нет. Нажмите +, чтобы создать стиль из выделенного текста." +"No variables fit here." = "Нет подходящих переменных." +"No variables yet. Create them in Local variables." = "Переменных пока нет. Создайте их в разделе «Локальные переменные»." +"Nobody else is here yet" = "Здесь пока больше никого нет" +"Noise" = "Шум" +"None" = "Нет" +"None." = "Нет." +"Normal" = "Обычный" +"Not bound" = "Не привязано" +"Not in a live session" = "Нет активной совместной сессии" +"Not in this font" = "Нет в этом шрифте" +"Not properties, kept as written: {names}" = "Не свойства, оставлены как есть: {names}" +"Not read yet" = "Ещё не прочитано" +"Not shared" = "Не опубликовано" +"Note {n}" = "Заметка {n}" +"Nothing changed since this was marked ready for dev." = "С момента отметки «Готово к разработке» изменений нет." +"Nothing here yet. Set a frame's status to list it." = "Здесь пока пусто. Задайте статус Frame, чтобы он появился в списке." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Онлайн-файлов пока нет. Нажмите «Поделиться» в файле, чтобы опубликовать его и добавить сюда." +"Nothing to export" = "Нечего экспортировать" +"Nov" = "нояб." +"Now" = "Сейчас" +"Nudge (Shift: big nudge)" = "Сдвиг (Shift: большой шаг)" +"Nudge amount" = "Шаг сдвига" +"Number" = "Число" +"Number ↑" = "Число ↑" +"Number ↓" = "Число ↓" +"Numbered list" = "Нумерованный список" +"Object" = "Объект" +"Oct" = "окт." +"Off" = "Выкл." +"Offered first when filling the slot (any content is allowed)" = "Предлагается первым при заполнении слота (можно использовать любое содержимое)" +"Offered first when swapping the instance (any component is allowed)" = "Предлагается первым при замене экземпляра (можно использовать любой компонент)" +"Offset" = "Смещение" +"Oldest first" = "Сначала старые" +"On click" = "По нажатию" +"On drag" = "При перетаскивании" +"On this page" = "На этой странице" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Одно объявление в пяти популярных размерах: квадрат, портрет, история, ссылка и баннер" +"Online" = "Онлайн" +"Online files" = "Онлайн-файлы" +"Online sharing is off: files are shared on the local network only." = "Онлайн-публикация отключена: файлы доступны только в локальной сети." +"Only the first changed layers are listed." = "Показаны только первые изменённые слои." +"Only the first {n} changed layers are listed." = "Показаны только первые {n} изменённых слоёв." +"Only your threads" = "Только ваши обсуждения" +"Opacity" = "Непрозрачность" +"Opacity & blend" = "Непрозрачность и наложение" +"Opacity 10%…90%, 0: 100%" = "Непрозрачность 10%…90%, 0: 100%" +"Open" = "Открыть" +"Open .figcraft, .svg or images" = "Открыть .figcraft, .svg или изображения" +"Open branch" = "Открыть ветку" +"Open file" = "Открыть файл" +"Open files" = "Открыть файлы" +"Open in focus view" = "Открыть в режиме фокуса" +"Open in new window" = "Открыть в новом окне" +"Open invite links in FigCraft" = "Открывать ссылки приглашения в FigCraft" +"Open link" = "Открыть ссылку" +"Open link {url}" = "Открыть ссылку {url}" +"Open overlay" = "Открыть оверлей" +"Open prototype preview" = "Открыть предпросмотр прототипа" +"Open variables ({count})" = "Открыть переменные ({count})" +"Opened “{name}” in a new tab" = "«{name}» открыт в новой вкладке" +"Open|comments" = "Открытые" +"Options" = "Параметры" +"Orange" = "Оранжевый" +"Order" = "Порядок" +"Other apps may not open every .fig file exported here." = "Другие приложения могут открыть не каждый экспортированный здесь файл .fig." +"Others lose access and keep their copy; the file stays on the server for you" = "Другие участники потеряют доступ, сохранив свои копии; файл останется на сервере для вас" +"Others type this under Share ▸ Join with a code" = "Другие участники вводят это в «Поделиться» ▸ «Подключиться по коду»" +"Outline stroke" = "Преобразовать обводку в контур" +"Outline text" = "Преобразовать текст в контуры" +"Outlines" = "Контуры" +"Outside" = "Снаружи" +"Overflow" = "Переполнение" +"Overlay" = "Оверлей" +"Overlay position" = "Положение оверлея" +"Overridden here (the collection it extends has another value)" = "Переопределено здесь (в исходной коллекции другое значение)" +"Overrides" = "Переопределения" +"Owner token" = "Токен владельца" +"Owner token copied" = "Токен владельца скопирован" +"PDF print options" = "Параметры печати PDF" +"PDF print settings" = "Настройки печати PDF" +"Padding" = "Внутренние отступы" +"Page" = "Страница" +"Pages" = "Страницы" +"Pages: {pages}" = "Страницы: {pages}" +"Paint bucket" = "Заливка области" +"Pan the canvas" = "Перемещение по холсту" +"Pan with nothing selected (Shift: farther)" = "Перемещение без выделения (Shift: дальше)" +"Panels" = "Панели" +"Paper" = "Бумага" +"Paragraph indent" = "Абзацный отступ" +"Paragraph indent applies to left-aligned text" = "Абзацный отступ применяется к тексту с выравниванием влево" +"Paragraph spacing" = "Интервал между абзацами" +"Pass through" = "Пропуск" +"Paste a link (https://…)" = "Вставьте ссылку (https://…)" +"Paste a team library link (a view link)" = "Вставьте ссылку на командную библиотеку (для просмотра)" +"Paste here" = "Вставить здесь" +"Paste to replace" = "Вставить с заменой" +"Pattern" = "Паттерн" +"Pattern alignment" = "Выравнивание паттерна" +"Pattern scale" = "Масштаб паттерна" +"Pattern source" = "Источник паттерна" +"Pen" = "Перо" +"Pencil" = "Карандаш" +"People in this file" = "Участники в этом файле" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Участники подключаются по коду из любой сети. Пока файл опубликован, он хранится на {server}." +"People on your network join with a code. Nothing goes through a server." = "Участники вашей сети подключаются по коду. Сервер не используется." +"Phone" = "Телефон" +"Pick color" = "Пипетка" +"Pick color (I)" = "Пипетка (I)" +"Pick one font family first" = "Сначала выберите одно семейство шрифта" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Выберите инструмент Frame, затем протяните область на холсте или выберите размер устройства" +"Pin a property" = "Закрепить свойство" +"Pinch" = "Сужение" +"Pixel grid" = "Пиксельная сетка" +"Pixel preview 1x" = "Пиксельный предпросмотр 1x" +"Pixel preview 2x" = "Пиксельный предпросмотр 2x" +"Place a shape or frame (its tool chosen)" = "Разместить фигуру или Frame (выбранным инструментом)" +"Place automatically" = "Разместить автоматически" +"Place image" = "Разместить изображение" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Плагины работают в песочнице FigCraft без доступа к файлам, сети и часам. Устанавливайте только плагины, которым доверяете." +"Plugins" = "Плагины" +"Plus darker" = "Линейное затемнение" +"Plus lighter" = "Линейное осветление" +"Point" = "Точка" +"Points" = "Точки" +"Polygon" = "Многоугольник" +"Polygons" = "Многоугольники" +"Port" = "Порт" +"Position" = "Положение" +"Post" = "Отправить" +"Preferences" = "Настройки" +"Preferred" = "Предпочтительные" +"Present" = "Презентация" +"Present or preview" = "Презентация или предпросмотр" +"Present this flow" = "Запустить этот сценарий" +"Presentation" = "Презентация" +"Presentation slides" = "Слайды презентации" +"Presenting: comments" = "Презентация: комментарии" +"Presenting: cycle the size options" = "Презентация: переключить размер" +"Presenting: full screen" = "Презентация: полный экран" +"Presenting: next frame" = "Презентация: следующий Frame" +"Presenting: previous frame" = "Презентация: предыдущий Frame" +"Presenting: restart" = "Презентация: начать заново" +"Preserve scroll position" = "Сохранять положение прокрутки" +"Presets" = "Пресеты" +"Press a key…" = "Нажмите клавишу…" +"Playground" = "Песочница" +"Preview" = "Предпросмотр" +"Preview this flow" = "Предпросмотр этого сценария" +"Preview this frame" = "Предпросмотр этого Frame" +"Previous ready for dev" = "Предыдущий готовый к разработке" +"Progressive" = "Прогрессивный" +"Properties" = "Свойства" +"Prototype" = "Прототип" +"Prototype preview" = "Предпросмотр прототипа" +"Prototype settings" = "Настройки прототипа" +"Publish" = "Опубликовать" +"Publish changes" = "Опубликовать изменения" +"Publish to another server…" = "Опубликовать на другом сервере…" +"Publish to server" = "Опубликовать на сервере" +"Published version {v}" = "Опубликована версия {v}" +"Published version {v} to the server" = "Версия {v} опубликована на сервере" +"Published version {v}: save the file so other files can use it" = "Опубликована версия {v}: сохраните файл, чтобы её можно было использовать в других файлах" +"Published · {people}" = "Опубликовано · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Для публикации на сервере нужно настольное приложение; в браузере можно подключаться к файлам сервера" +"Publishing with the server's key." = "Публикация с ключом сервера." +"Publishing…" = "Публикация…" +"Push" = "Вытеснение" +"Push changes to main component" = "Перенести изменения в основной компонент" +"Put text on path" = "Разместить текст на пути" +"R" = "R" +"Radial" = "Радиальный" +"Radius" = "Радиус" +"Ratio" = "Соотношение" +"Reading the library…" = "Чтение библиотеки…" +"Ready for dev" = "Готово к разработке" +"Real name" = "Настоящее имя" +"Recent" = "Недавние" +"Recent files" = "Недавние файлы" +"Recents" = "Недавние" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "После сбоя восстановлен {n} несохранённый файл: сохраните его", few = "После сбоя восстановлены {n} несохранённых файла: сохраните их", many = "После сбоя восстановлено {n} несохранённых файлов: сохраните их", other = "После сбоя восстановлено {n} несохранённого файла: сохраните их" } +"Rectangle" = "Прямоугольник" +"Rectangles" = "Прямоугольники" +"Red" = "Красный" +"Reduce motion" = "Уменьшить движение" +"Refraction" = "Преломление" +"Regular expression" = "Регулярное выражение" +"Remove" = "Удалить" +"Remove all" = "Удалить всё" +"Remove annotation" = "Удалить аннотацию" +"Remove auto layout" = "Убрать автолейаут" +"Remove dev resource" = "Удалить ресурс для разработки" +"Remove export setting" = "Удалить настройку экспорта" +"Remove from recents" = "Убрать из недавних" +"Remove from server" = "Удалить с сервера" +"Remove from server…" = "Удалить с сервера…" +"Remove guide" = "Удалить направляющую" +"Remove interaction" = "Удалить взаимодействие" +"Remove library from this file" = "Удалить библиотеку из этого файла" +"Remove min and max" = "Убрать минимум и максимум" +"Remove min and max height" = "Убрать минимальную и максимальную высоту" +"Remove min and max width" = "Убрать минимальную и максимальную ширину" +"Remove regions" = "Удалить области" +"Remove slot" = "Убрать слот" +"Remove starting point" = "Убрать начальную точку" +"Remove stop" = "Удалить точку градиента" +"Remove text from path" = "Снять текст с пути" +"Remove the else and its actions" = "Удалить «Иначе» и его действия" +"Remove this condition and its actions" = "Удалить это условие и его действия" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Удалить файл с сервера? Его больше нельзя будет открывать и публиковать оттуда. Ваша копия останется открытой здесь." +"Remove {name} from {server}?" = "Удалить {name} с {server}?" +"Removed" = "Удалено" +"Removed from the server" = "Удалено с сервера" +"Removed since it was marked" = "Удалено после отметки" +"Rename" = "Переименовать" +"Rename duplicated layers" = "Переименовывать дубликаты слоёв" +"Rename folder" = "Переименовать папку" +"Rename group" = "Переименовать группу" +"Rename layers" = "Переименовать слои" +"Rename mode" = "Переименовать режим" +"Rename on the server" = "Переименовать на сервере" +"Rename on the server…" = "Переименовать на сервере…" +"Rename page" = "Переименовать страницу" +"Rename to" = "Новое имя" +"Replace all" = "Заменить всё" +"Replace mixed export settings" = "Заменить разные настройки экспорта" +"Replace with" = "Заменить на" +"Replace with a new interaction" = "Заменить новым взаимодействием" +"Reply" = "Ответить" +"Report" = "Пожаловаться" +"Report this file…" = "Пожаловаться на этот файл…" +"Reset" = "Сбросить" +"Reset adjustments" = "Сбросить коррекцию" +"Reset all changes" = "Сбросить все изменения" +"Reset all overrides on this layer" = "Сбросить все переопределения слоя" +"Reset overrides" = "Сбросить переопределения" +"Reset slot" = "Сбросить слот" +"Reset this override" = "Сбросить переопределение" +"Reset to style" = "Сбросить к стилю" +"Reset to the inherited value" = "Вернуть унаследованное значение" +"Resize by the nudge amount" = "Изменить размер на шаг сдвига" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Менять размер Frame под экран устройства (или окно, если устройство не выбрано): ограничения и автолейаут подстраиваются" +"Resize to fit" = "Подогнать размер" +"Resolve" = "Решить" +"Resolve change" = "Разрешить изменение" +"Resolved" = "Решённые" +"Resources" = "Ресурсы" +"Responsive" = "Адаптивно" +"Restart" = "Начать заново" +"Restore" = "Восстановить" +"Restore main component" = "Восстановить основной компонент" +"Restore this version" = "Восстановить эту версию" +"Restored “{name}”" = "«{name}» восстановлено" +"Reverse the order" = "Обратить порядок" +"Reversed triangle" = "Обратный треугольник" +"Review and merge" = "Проверить и объединить" +"Ribbon" = "Лента" +"Right" = "Справа" +"Right side" = "Правая сторона" +"Right side width" = "Ширина правой стороны" +"Rotate 90°" = "Повернуть на 90°" +"Rotate 90° right" = "Повернуть на 90° вправо" +"Rotate to landscape" = "Повернуть в альбомную ориентацию" +"Rotate to portrait" = "Повернуть в портретную ориентацию" +"Rotation" = "Поворот" +"Rotation jitter" = "Колебание угла" +"Round" = "Круглое" +"Rounded" = "Скруглённый" +"Row" = "Строка" +"Row gap" = "Интервал между строками" +"Row sizes" = "Размеры строк" +"Row span" = "Объединение строк" +"Rows" = "Строки" +"Rows {count}" = "Строки: {count}" +"Rulers" = "Линейки" +"Run" = "Запустить" +"Run {label}" = "Запустить {label}" +"Search templates" = "Поиск шаблонов" +"See all templates" = "Все шаблоны" +"Select layers and they space themselves as their content changes" = "Выделите слои: интервалы между ними будут подстраиваться при изменении содержимого" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Как у слоя" +"Saturation" = "Насыщенность" +"Save" = "Сохранить" +"Save as" = "Сохранить как" +"Save as preset…" = "Сохранить как пресет…" +"Save version {key}" = "Сохранить версию {key}" +"Saved" = "Сохранено" +"Saved {name}" = "Сохранено: {name}" +"Say something" = "Напишите сообщение" +"Scale" = "Масштаб" +"Scatter" = "Рассеивание" +"Scoping: where pickers offer it" = "Область применения: где предлагается переменная" +"Screen" = "Экран" +"Scroll behavior" = "Поведение при прокрутке" +"Scroll to" = "Прокрутить к" +"Scroll to layer" = "Прокрутить к слою" +"Scroll with parent" = "Прокручивать с родителем" +"Search plugins" = "Поиск плагинов" +"Search" = "Поиск" +"Search actions, layers and tools" = "Поиск действий, слоёв и инструментов" +"Search assets" = "Поиск ресурсов" +"Search comments" = "Поиск комментариев" +"Search fonts" = "Поиск шрифтов" +"Search layers" = "Поиск слоёв" +"Search shortcuts" = "Поиск сочетаний клавиш" +"Section" = "Секция" +"Section color" = "Цвет секции" +"Sections" = "Секции" +"Select a frame and add a starting point to present it as a flow." = "Выделите Frame и добавьте начальную точку, чтобы запустить его как сценарий." +"Select a frame to preview it" = "Выделите Frame для предпросмотра" +"Select a layer to see its properties and code" = "Выделите слой, чтобы увидеть его свойства и код" +"Select all" = "Выделить всё" +"Select all with same" = "Выделить всё с одинаковым" +"Select layer" = "Выделить слой" +"Select layers to apply the style to" = "Выделите слои для применения стиля" +"Select overlapping shapes" = "Выделите пересекающиеся фигуры" +"Select points to edit their position, radius and mirroring" = "Выделите точки для изменения положения, радиуса и зеркальности" +"Select slot" = "Выделить слот" +"Selection" = "Выделение" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Область выделения {w} на {h} в {x}, {y}: стрелки — перемещение, Shift+стрелки или {cmd}+стрелки — размер, Enter — выделить, Escape — отменить" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Область выделения: стрелки — перемещение, Shift+стрелки или {cmd}+стрелки — размер, Enter — выделить, Escape — отменить" +"Send backward" = "На уровень назад" +"Send report" = "Отправить жалобу" +"Send to back" = "На задний план" +"Sep" = "сент." +"Server address (ws://host:47900/)" = "Адрес сервера (ws://host:47900/)" +"Server address" = "Адрес сервера" +"Server for online sharing" = "Сервер для онлайн-публикации" +"Set as default" = "Задать по умолчанию" +"Set variable" = "Задать переменную" +"Set variable mode" = "Задать режим переменных" +"Set variable {name}" = "Задать переменную {name}" +"Set {name} to {value}" = "Задать {name}: {value}" +"Shadows" = "Тени" +"Shape" = "Фигура" +"Shape builder" = "Конструктор фигур" +"Shape tools" = "Инструменты фигур" +"Share" = "Поделиться" +"Share this file" = "Поделиться файлом" +"Share with a code" = "Поделиться по коду" +"Share: live session, export" = "Поделиться: совместная сессия, экспорт" +"Shared" = "Опубликовано" +"Shared: copy its code from the file's menu" = "Опубликовано: скопируйте код в меню файла" +"Sharing a file needs the desktop app; here you can join one." = "Для публикации файла нужно настольное приложение; здесь можно подключиться к опубликованному файлу." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Общий доступ в локальной сети (онлайн-публикация отключена: сервер не задан в настройках)." +"Sharing on your network · {people}" = "Общий доступ в вашей сети · {people}" +"Sharing online · {people}" = "Общий доступ онлайн · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Общий доступ остановлен: файл остаётся на сервере, доступен только вам" +"Show" = "Показать" +"Show all templates" = "Показать все шаблоны" +"Show as grid" = "Показать сеткой" +"Show as list" = "Показать списком" +"Show autosaves" = "Показать автосохранения" +"Show behind transparent areas" = "Показать за прозрачными областями" +"Show comments (Shift+C)" = "Показать комментарии (Shift+C)" +"Show device frame" = "Показать рамку устройства" +"Show dimensions on objects" = "Показать размеры объектов" +"Show hotspot hints" = "Показать интерактивные области" +"Show in Explorer" = "Показать в Проводнике" +"Show in Finder" = "Показать в Finder" +"Show in folder" = "Показать в папке" +"Show only this on the canvas" = "Показать на холсте только это" +"Show open" = "Показать открытые" +"Show section contents" = "Показать содержимое секции" +"Show this layer" = "Показать этот слой" +"Show usage" = "Показать использование" +"Show/Hide" = "Показать/скрыть" +"Show/Hide UI" = "Показать/скрыть интерфейс" +"Side by side" = "Рядом" +"Sidebar" = "Боковая панель" +"Simplify stroke" = "Упростить обводку" +"Size" = "Размер" +"Size jitter" = "Колебание размера" +"Skip ink" = "Пропускать пересечения с буквами" +"Slashed zero" = "Перечёркнутый ноль" +"Slice" = "Срез" +"Slices" = "Срезы" +"Slide in" = "Въезд" +"Slide out" = "Выезд" +"Slot" = "Слот" +"Slot content" = "Содержимое слота" +"Small capitals" = "Капительные буквы" +"Small caps" = "Капитель" +"Smart animate" = "Smart animate" +"Snap to pixel grid" = "Привязка к пиксельной сетке" +"Social and slides" = "Соцсети и слайды" +"Social media" = "Соцсети" +"Social post sizes" = "Размеры публикаций для соцсетей" +"Soft light" = "Мягкий свет" +"Solid" = "Сплошная" +"Some variants have the same property values: give each one a unique combination" = "Некоторые варианты имеют одинаковые значения свойств: задайте каждому уникальное сочетание" +"Sort" = "Сортировка" +"Sort by" = "Сортировать по" +"Sort by date" = "Сортировать по дате" +"Sort by page" = "Сортировать по странице" +"Sort by {column}" = "Сортировать по {column}" +"Space around" = "Отступы вокруг" +"Space between" = "Отступы между" +"Space drag" = "Пробел и перетаскивание" +"Space evenly" = "Равные отступы" +"Space+drag" = "Пробел+перетаскивание" +"Spacing" = "Интервал" +"Spam, stolen work, abuse…" = "Спам, украденные работы, оскорбления…" +"Splay" = "Расхождение" +"Spotlight me" = "Показывать мой экран всем" +"Spread" = "Размах" +"Square" = "Квадрат" +"Standard" = "Стандартный" +"Standard ligatures" = "Стандартные лигатуры" +"Star" = "Звезда" +"Stars" = "Звёзды" +"Start" = "Начало" +"Start ascending from / stop descending at" = "Начать увеличение с / закончить уменьшение на" +"Start from a blank canvas" = "Начать с пустого холста" +"Start from a template" = "Начать с шаблона" +"Start on path" = "Начало на пути" +"Start point" = "Начальная точка" +"Start sharing" = "Включить общий доступ" +"Start sharing with the Share button, then pass on the code it gives" = "Включите общий доступ кнопкой «Поделиться», затем передайте полученный код" +"Starting to share…" = "Включение общего доступа…" +"Status" = "Статус" +"Sticky" = "Закреплённый" +"Stop" = "Остановить" +"Stop following {name}" = "Перестать следить за {name}" +"Stop session" = "Завершить сессию" +"Stop sharing" = "Остановить общий доступ" +"Stop spotlighting" = "Прекратить показ всем" +"Stop {n} {hex}" = "Точка {n} {hex}" +"Stretch" = "Растянуть" +"Strikethrough" = "Зачёркивание" +"String" = "Строка" +"Stroke" = "Обводка" +"Stroke style" = "Стиль обводки" +"Stroke weight" = "Толщина обводки" +"Strokes" = "Обводки" +"Style" = "Стиль" +"Styles" = "Стили" +"Stylistic set 1" = "Стилистический набор 1" +"Stylistic set 2" = "Стилистический набор 2" +"Stylistic set 3" = "Стилистический набор 3" +"Stylistic set 4" = "Стилистический набор 4" +"Stylistic set 5" = "Стилистический набор 5" +"Subtract" = "Вычитание" +"Subtracts" = "Вычитания" +"Suffix" = "Суффикс" +"Suspend snapping while dragging" = "Временно отключить привязку при перетаскивании" +"Swap instance" = "Заменить экземпляр" +"Swap library: use another library's assets of the same names" = "Заменить библиотеку: использовать одноимённые ресурсы другой библиотеки" +"Swap orientation" = "Сменить ориентацию" +"Swap overlay" = "Заменить оверлей" +"Swap to {name}" = "Заменить на {name}" +"Swap width and height" = "Поменять ширину и высоту" +"Swapped instance" = "Экземпляр заменён" +"Swapped {n} assets" = { one = "Заменён {n} ресурс", few = "Заменены {n} ресурса", many = "Заменено {n} ресурсов", other = "Заменено {n} ресурса" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Заменён {n} ресурс; без совпадений: {missed}, для них сохранена старая библиотека", few = "Заменены {n} ресурса; без совпадений: {missed}, для них сохранена старая библиотека", many = "Заменено {n} ресурсов; без совпадений: {missed}, для них сохранена старая библиотека", other = "Заменено {n} ресурса; без совпадений: {missed}, для них сохранена старая библиотека" } +"Sweep" = "Угол дуги" +"System" = "Системный" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Таблица" +"Tablet" = "Планшет" +"Tabular numbers" = "Табличные цифры" +"Take the values from the first selected layer" = "Взять значения первого выделенного слоя" +"Taper" = "Сужение к концу" +"Taper both ends" = "Сужение с обоих концов" +"Tell the server's operator what is wrong with it" = "Сообщить оператору сервера о проблеме" +"Temperature" = "Температура" +"Template" = "Шаблон" +"Templates" = "Шаблоны" +"Terms" = "Условия" +"Text" = "Текст" +"Text align" = "Выравнивание текста" +"Text content" = "Содержимое текста" +"Text style" = "Текстовый стиль" +"Text styles" = "Текстовые стили" +"Texts" = "Тексты" +"Texture" = "Текстура" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "На холсте цвета отображаются в sRGB; экспортированные PNG, JPG и WebP помечены профилем Display P3." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Файл удаляется с сервера: его больше нельзя будет открыть или подключиться к нему оттуда. Отменить это нельзя. Уже открытые копии остаются у участников." +"The file now holds another library" = "Теперь файл содержит другую библиотеку" +"The file now holds another library (\"{name}\")" = "Теперь файл содержит другую библиотеку («{name}»)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Файлы, которыми вы поделились или которые опубликовали онлайн с этого компьютера. Они остаются на сервере, пока вы их не удалите." +"The link carries the session's secret: share it only with people you invite." = "Ссылка содержит секрет сессии: передавайте её только приглашённым участникам." +"The name in your online files and on the server. The document inside keeps its own name." = "Имя в списке онлайн-файлов и на сервере. Документ внутри сохраняет собственное имя." +"The selection uses several text styles: click to apply one" = "В выделении несколько текстовых стилей: нажмите, чтобы применить один" +"The server deletes this file if nobody opens it before {date}." = "Сервер удалит файл, если никто не откроет его до {date}." +"The whole file" = "Весь файл" +"Theme" = "Тема" +"There's no destination for that action" = "Для этого действия не задано назначение" +"Thickness" = "Толщина" +"This branch can't be reviewed: {e}" = "Не удалось проверить ветку: {e}" +"This branch was merged or archived." = "Ветка объединена или архивирована." +"This computer only" = "Только этот компьютер" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Этот компьютер сохраняет токен владельца для управления файлом из списка «Онлайн» на главной. Скопируйте токен для управления с другого устройства: это пароль к файлу, он больше не будет показан." +"This condition is edited as an expression" = "Это условие редактируется как выражение" +"This file" = "Этот файл" +"This file has unsaved changes" = "В файле есть несохранённые изменения" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Файл находится на {server} и доступен только вам. Включите общий доступ, чтобы пригласить других по новому коду." +"This frame is new on the branch." = "Этот Frame добавлен в ветке." +"This frame isn't on the branch." = "Этого Frame нет в ветке." +"This server is open to the public: no key needed." = "Сервер общедоступен: ключ не нужен." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Сервер требует ключ: добавьте ?key=… к адресу (`figcraft-server key` выводит его на компьютере сервера)." +"This server takes files only with its key: see Advanced." = "Сервер принимает файлы только с ключом: см. «Дополнительно»." +"Tidy up" = "Упорядочить" +"Tile" = "Повторение" +"Tile type" = "Тип повторения" +"Tint" = "Оттенок" +"Title" = "Заголовок" +"Title case" = "Каждое Слово С Прописной" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Чтобы поделиться библиотекой с командой, опубликуйте файл на сервере («Поделиться» ▸ «Опубликовать на сервере…»), затем опубликуйте там библиотеку." +"Toggle" = "Переключить" +"Toggle light/dark theme" = "Переключить светлую/тёмную тему" +"Toggle visibility" = "Переключить видимость" +"Tools" = "Инструменты" +"Top" = "Сверху" +"Top & bottom" = "Сверху и снизу" +"Top left radius" = "Радиус верхнего левого угла" +"Top right radius" = "Радиус верхнего правого угла" +"Transform" = "Трансформация" +"Triangle arrow" = "Треугольная стрелка" +"Trigger" = "Триггер" +"True" = "Истина" +"Truncate text" = "Обрезать текст" +"Truncate with an ellipsis" = "Обрезать с многоточием" +"Try again" = "Повторить" +"Try another search or filter." = "Попробуйте другой запрос или фильтр." +"Try another word, or look at every kind." = "Попробуйте другое слово или посмотрите все категории." +"Turn a layer into a component, reuse it, and change every copy at once" = "Превратите слой в компонент, используйте повторно и изменяйте все копии сразу" +"Two digits" = "Две цифры" +"Type" = "Тип" +"Type settings" = "Настройки типографики" +"Type the join code you were given, or paste an invite link." = "Введите полученный код подключения или вставьте ссылку приглашения." +"Typography" = "Типографика" +"Underline" = "Подчёркивание" +"Undo takes back only your own changes; what others changed since stays." = "Отмена затрагивает только ваши изменения; последующие изменения других участников сохраняются." +"Ungroup" = "Разгруппировать" +"Uniform" = "Равномерный" +"Union" = "Объединение" +"Unions" = "Объединения" +"Unknown — treated as sRGB" = "Неизвестный — считается sRGB" +"Unread" = "Непрочитанные" +"Unresolve" = "Снова открыть" +"Unsaved changes" = "Несохранённые изменения" +"Unsaved changes in {names}. Close the window and lose them?" = "Несохранённые изменения в {names}. Закрыть окно и потерять их?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Есть несохранённые изменения: сохраните ({save}) или закройте вкладку нажатием с {alt}, чтобы отбросить их" +"Unsaved changes: save first, or close again with {key} to discard" = "Есть несохранённые изменения: сохраните или закройте ещё раз с {key}, чтобы отбросить их" +"Up" = "Вверх" +"Update plugin" = "Обновить плагин" +"Update" = "Обновить" +"Update from main" = "Обновить из основной ветки" +"Update from selection" = "Обновить из выделения" +"Updated {components} and {styles}" = "Обновлено: {components} и {styles}" +"Updated {name}" = "Обновлено: {name}" +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Загружает файл на сервер FigCraft и синхронизирует эту вкладку с ним. Вы получите ссылки для редактирования и просмотра." +"Uppercase" = "Прописные" +"Use a fixed value" = "Использовать фиксированное значение" +"Use an expression, e.g. count + 1" = "Использовать выражение, например count + 1" +"Use as mask" = "Использовать как маску" +"Use auto layout" = "Использовать автолейаут" +"Use number keys for opacity" = "Задавать непрозрачность цифровыми клавишами" +"Use the layer's fill" = "Использовать заливку слоя" +"Used in {files} · {instances}" = "Используется в {files} · {instances}" +"Username" = "Имя пользователя" +"Value" = "Значение" +"Uses variables: each layer shows them in its own mode" = "Используются переменные: каждый слой отображает их в своём режиме" +"Variable" = "Переменная" +"Variable collections" = "Коллекции переменных" +"Variable modes" = "Режимы переменных" +"Variable width" = "Переменная ширина" +"Variables" = "Переменные" +"Variables JSON…" = "JSON переменных…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Переменные хранят значения для повторного использования (цвета, числа, строки, логические значения) с режимами, например светлым и тёмным." +"Variant" = "Вариант" +"Vector" = "Вектор" +"Vectors" = "Векторы" +"Version" = "Версия" +"Version history" = "История версий" +"Version {version}" = "Версия {version}" +"Version {v} · {components} · {styles} · {variables}" = "Версия {v} · {components} · {styles} · {variables}" +"Vertical" = "Вертикально" +"Vertical align" = "Вертикальное выравнивание" +"Vertical hex" = "Вертикальный шестиугольник" +"Vertical padding" = "Вертикальные внутренние отступы" +"Vertical spacing" = "Вертикальный интервал" +"Vertical trim" = "Вертикальная обрезка" +"View" = "Вид" +"View link" = "Ссылка для просмотра" +"View link copied" = "Ссылка для просмотра скопирована" +"View only" = "Только просмотр" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Только просмотр: вы видите все изменения сразу, но не можете редактировать. Выйдите из сессии, чтобы сохранить редактируемую копию." +"Viewing “{name}”" = "Просмотр «{name}»" +"Violet" = "Фиолетовый" +"Visibility" = "Видимость" +"Visible" = "Видимый" +"Vivid" = "Яркий" +"Warm" = "Тёплый" +"Watch" = "Часы" +"Wavy" = "Волнистый" +"Web" = "Веб" +"Websites" = "Веб-сайты" +"WebSocket URL" = "URL WebSocket" +"WebSocket URL copied" = "URL WebSocket скопирован" +"Wedge" = "Клин" +"Weight" = "Толщина" +"What you try here changes this preview and the code below, not the file" = "Здесь меняются только предпросмотр и код ниже; файл остаётся без изменений" +"What changed since it was marked ready for dev" = "Изменения после отметки «Готово к разработке»" +"What is wrong with this file? The server's operator gets your report." = "Что не так с файлом? Оператор сервера получит вашу жалобу." +"What it's for" = "Назначение" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Исходное состояние неизвестно: изменение произошло до того, как FigCraft начал сохранять копии Frame, готовых к разработке. Разрешите изменение (или снова отметьте готовность), чтобы сравнивать последующие изменения." +"While hovering" = "При наведении" +"While pressing" = "При удержании" +"White" = "Белый" +"Who runs it, its terms and how long it keeps files" = "Оператор, условия и срок хранения файлов" +"Why it changed" = "Причина изменения" +"Widgets" = "Виджеты" +"Width" = "Ширина" +"Width point" = "Точка ширины" +"Width profile" = "Профиль ширины" +"Wireframe kit" = "Набор вайрфреймов" +"With the layers' export settings" = "С настройками экспорта слоёв" +"Wrap" = "Перенос" +"Wrap in new section" = "Поместить в новую секцию" +"Yellow" = "Жёлтый" +"Yesterday" = "Вчера" +"You" = "Вы" +"You'll get the shared file as a new tab and edit it together." = "Общий файл откроется в новой вкладке для совместного редактирования." +"You're spotlighting: everyone follows your view" = "Вы показываете экран всем: участники следуют за вашим видом" +"Your copy of the file stays open" = "Ваша копия файла останется открытой" +"Your copy stays open; the file stays on the server" = "Ваша копия останется открытой; файл останется на сервере" +"Your name" = "Ваше имя" +"Your name in comments" = "Ваше имя в комментариях" +"Your system asks for reduced motion: animations stay off." = "В системе включено уменьшение движения: анимации отключены." +"Z" = "Z" +"Z to A" = "От Я до А" +"Zoom" = "Масштаб" +"Zoom around the pointer" = "Масштабировать вокруг указателя" +"Zoom device to fill window" = "Масштабировать устройство до заполнения окна" +"Zoom in" = "Увеличить" +"Zoom out" = "Уменьшить" +"Zoom to 100%" = "Масштаб 100%" +"Zoom to 200%" = "Масштаб 200%" +"Zoom to 50%" = "Масштаб 50%" +"Zoom to fit" = "Вписать всё" +"Zoom to selection" = "Масштаб по выделению" +"Zoom tool: click to zoom in, drag an area" = "Инструмент масштаба: нажмите для увеличения или выделите область" +"Zoom tool: zoom out" = "Инструмент масштаба: уменьшение" +"and {n} more" = { one = "и ещё {n}", few = "и ещё {n}", many = "и ещё {n}", other = "и ещё {n}" } +"bleed marks" = "метки вылета" +"by {author}" = "автор: {author}" +"call any command its access allows" = "вызывать любые команды в рамках разрешений" +"call only: {commands}" = "вызывать только: {commands}" +"can edit the document" = "может редактировать документ" +"crop marks" = "метки обреза" +"development: {path}" = "разработка: {path}" +"e.g. count + 1" = "например, count + 1" +"e.g. count >= 3 and not done" = "например, count >= 3 and not done" +"figcraft:// links from a browser, chat or email open this app and join the session." = "Ссылки figcraft:// из браузера, чата или почты открывают это приложение и подключают к сессии." +"from {server}" = "с {server}" +"hidden" = "скрыто" +"iOS" = "iOS" +"just now" = "только что" +"locked" = "заблокировано" +"no bleed" = "без вылета" +"nobody else here yet" = "здесь пока больше никого нет" +"now" = "сейчас" +"or" = "или" +"put a widget on the canvas" = "размещать виджет на холсте" +"read and edit the document" = "читать и редактировать документ" +"read the document" = "читать документ" +"reads the document" = "читает документ" +"the plugin" = "плагин" +"this session only" = "только эта сессия" +"yesterday" = "вчера" +"{instances} in {files}" = "{instances} в {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: черновой перевод, ожидает проверки носителем языка." +"{length} bleed" = "вылет {length}" +"{month} {day}, {year}" = "{day} {month} {year}" +"{name} cleared from the list" = "{name} убран из списка" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} забыт на этом компьютере; файл остаётся на {server}" +"{name} is on the server." = "{name} находится на сервере." +"{name} is spotlighting" = "{name} показывает экран всем" +"{name}\nClick to apply to the selection" = "{name}\nНажмите, чтобы применить к выделению" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nПеретащите на холст или нажмите, чтобы разместить в центре вида.\nУдерживайте Alt (Option) при переносе на экземпляр, чтобы заменить его." +"{name} (from {library})" = "{name} (из {library})" +"{name} (you)" = "{name} (вы)" +"{name} ({layers} layers)" = "{name} (слоёв: {layers})" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, по умолчанию {default}. Двойное нажатие на ползунок возвращает значение стиля; Escape при перетаскивании отменяет изменение." +"{name} from {path}" = "{name} из {path}" +"{name} was removed from the server" = "{name} удалён с сервера" +"{name} · deleted on main" = "{name} · удалено в основной ветке" +"{name} · deleted on the branch" = "{name} · удалено в ветке" +"{name} · not published" = "{name} · не опубликовано" +"{name} · on a server" = "{name} · на сервере" +"{name} · published version {v}" = "{name} · опубликована версия {v}" +"{name} · version {v} on the server" = "{name} · версия {v} на сервере" +"{name}, in a live session" = "{name}, в совместной сессии" +"{name}, online, open only to you" = "{name}, онлайн, доступен только вам" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} на {h}, выделено" +"{n} added" = { one = "Добавлено: {n}", few = "Добавлено: {n}", many = "Добавлено: {n}", other = "Добавлено: {n}" } +"{n} autosave versions" = { one = "{n} автосохранённая версия", few = "{n} автосохранённые версии", many = "{n} автосохранённых версий", other = "{n} автосохранённой версии" } +"{n} changed" = { one = "Изменено: {n}", few = "Изменено: {n}", many = "Изменено: {n}", other = "Изменено: {n}" } +"{n} columns" = { one = "{n} столбец", few = "{n} столбца", many = "{n} столбцов", other = "{n} столбца" } +"{n} components" = { one = "{n} компонент", few = "{n} компонента", many = "{n} компонентов", other = "{n} компонента" } +"{n} components and styles have newer versions" = { one = "{n} компонент или стиль имеет новую версию", few = "{n} компонента или стиля имеют новые версии", many = "{n} компонентов и стилей имеют новые версии", other = "{n} компонента или стиля имеют новые версии" } +"{n} conflicts to resolve" = { one = "{n} конфликт для разрешения", few = "{n} конфликта для разрешения", many = "{n} конфликтов для разрешения", other = "{n} конфликта для разрешения" } +"{n} days ago" = { one = "{n} день назад", few = "{n} дня назад", many = "{n} дней назад", other = "{n} дня назад" } +"{n} files" = { one = "{n} файл", few = "{n} файла", many = "{n} файлов", other = "{n} файла" } +"{n} hours ago" = { one = "{n} час назад", few = "{n} часа назад", many = "{n} часов назад", other = "{n} часа назад" } +"{n} instances" = { one = "{n} экземпляр", few = "{n} экземпляра", many = "{n} экземпляров", other = "{n} экземпляра" } +"{n} layers" = { one = "{n} слой", few = "{n} слоя", many = "{n} слоёв", other = "{n} слоя" } +"{n} layers as one file" = { one = "{n} слой в одном файле", few = "{n} слоя в одном файле", many = "{n} слоёв в одном файле", other = "{n} слоя в одном файле" } +"{n} layers selected" = { one = "Выделен {n} слой", few = "Выделены {n} слоя", many = "Выделено {n} слоёв", other = "Выделено {n} слоя" } +"{n} layers · {parts}" = { one = "{n} слой · {parts}", few = "{n} слоя · {parts}", many = "{n} слоёв · {parts}", other = "{n} слоя · {parts}" } +"{n} matches" = { one = "{n} совпадение", few = "{n} совпадения", many = "{n} совпадений", other = "{n} совпадения" } +"{n} minutes ago" = { one = "{n} минуту назад", few = "{n} минуты назад", many = "{n} минут назад", other = "{n} минуты назад" } +"{n} months ago" = { one = "{n} месяц назад", few = "{n} месяца назад", many = "{n} месяцев назад", other = "{n} месяца назад" } +"{n} of {total}" = "{n} из {total}" +"{n} people joined" = { one = "Подключился {n} участник", few = "Подключились {n} участника", many = "Подключились {n} участников", other = "Подключилось {n} участника" } +"{n} preferred components" = { one = "{n} предпочтительный компонент", few = "{n} предпочтительных компонента", many = "{n} предпочтительных компонентов", other = "{n} предпочтительного компонента" } +"{n} ready" = { one = "Готово: {n}", few = "Готово: {n}", many = "Готово: {n}", other = "Готово: {n}" } +"{n} removed" = { one = "Удалено: {n}", few = "Удалено: {n}", many = "Удалено: {n}", other = "Удалено: {n}" } +"{n} replies" = { one = "{n} ответ", few = "{n} ответа", many = "{n} ответов", other = "{n} ответа" } +"{n} rows" = { one = "{n} строка", few = "{n} строки", many = "{n} строк", other = "{n} строки" } +"{n} styles" = { one = "{n} стиль", few = "{n} стиля", many = "{n} стилей", other = "{n} стиля" } +"{n} variables" = { one = "{n} переменная", few = "{n} переменные", many = "{n} переменных", other = "{n} переменной" } +"{n} weeks ago" = { one = "{n} неделю назад", few = "{n} недели назад", many = "{n} недель назад", other = "{n} недели назад" } +"{n} years ago" = { one = "{n} год назад", few = "{n} года назад", many = "{n} лет назад", other = "{n} года назад" } +"{n}d" = "{n} д" +"{n}h" = "{n} ч" +"{n}m" = "{n} мин" +"{n}w" = "{n} нед" +"{page}, nothing selected" = "{page}, ничего не выделено" +"{profile} (current)" = "{profile} (текущий)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} — значение свойства; {property ? \"a\" : \"b\"} выбирает строку. Другие {…} остаются как есть." +"{server} can't say what became of your files (it is older than this app)." = "{server} не может сообщить, что стало с вашими файлами (версия сервера старее приложения)." +"{server} · No longer on the server" = "{server} · Больше нет на сервере" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Открыто {date}" +"{tool} hold {key}" = "{tool} удерживайте {key}" +"{value} isn't a colour" = "{value} не является цветом" +"{when} · edited" = "{when} · изменено" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Пробел или N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ стрелки" +"⌘ click" = "⌘ нажатие" +"⌘ scroll" = "⌘ прокрутка" +"⌥ drag" = "⌥ перетаскивание" + +[commands] +"align.bottom" = "Выровнять по нижнему краю" +"align.hcenter" = "Выровнять центры по горизонтали" +"align.left" = "Выровнять по левому краю" +"align.right" = "Выровнять по правому краю" +"align.top" = "Выровнять по верхнему краю" +"align.vcenter" = "Выровнять центры по вертикали" +"annotation.add" = "Добавить аннотацию" +"annotation.addCategory" = "Добавить категорию аннотаций" +"annotation.categories" = "Категории аннотаций" +"annotation.edit" = "Изменить аннотацию" +"annotation.editCategory" = "Изменить категорию аннотаций" +"annotation.list" = "Список аннотаций" +"annotation.remove" = "Удалить аннотацию" +"annotation.removeCategory" = "Удалить категорию аннотаций" +"app.actions" = "Действия" +"app.preferences" = "Настройки" +"arrange.back" = "На задний план" +"arrange.backward" = "На уровень назад" +"arrange.forward" = "На уровень вперёд" +"arrange.front" = "На передний план" +"arrange.packH" = "Уплотнить по горизонтали" +"arrange.packV" = "Уплотнить по вертикали" +"arrange.roundToPixels" = "Округлить до пикселей" +"arrange.tidy" = "Упорядочить" +"bool.exclude" = "Исключить выделение" +"bool.flatten" = "Свести булеву группу" +"bool.intersect" = "Пересечь выделение" +"bool.subtract" = "Вычесть выделение" +"bool.union" = "Объединить выделение" +"branch.archive" = "Архивировать ветку" +"branch.create" = "Создать ветку" +"branch.list" = "Список веток" +"branch.merge" = "Объединить ветку" +"branch.open" = "Открыть ветку" +"branch.review" = "Проверить ветку" +"branch.updateFromMain" = "Обновить из основной ветки" +"codeConnect.export" = "Экспортировать связи с кодом" +"codeConnect.import" = "Импортировать связи с кодом" +"codeConnect.remove" = "Отвязать от кода" +"codeConnect.set" = "Связать с кодом" +"collab.cursor" = "Показать курсор в точке" +"collab.cursorChat" = "Чат у курсора" +"collab.diff" = "Показать изменения синхронизации" +"collab.files" = "Ваши онлайн-файлы" +"collab.follow" = "Следить" +"collab.forgetFile" = "Забыть онлайн-файл" +"collab.host" = "Начать совместную сессию" +"collab.join" = "Подключиться к совместной сессии" +"collab.leave" = "Покинуть совместную сессию" +"collab.manageFile" = "Управлять опубликованным файлом" +"collab.openFile" = "Открыть онлайн-файл" +"collab.peers" = "Участники в этом файле" +"collab.publish" = "Опубликовать на сервере" +"collab.recoverFile" = "Восстановить онлайн-файл" +"collab.registerLinks" = "Открывать ссылки приглашения в FigCraft" +"collab.report" = "Пожаловаться на файл оператору сервера" +"collab.serverInfo" = "Информация о сервере" +"collab.share" = "Поделиться по коду подключения" +"collab.spotlight" = "Показывать мой экран всем" +"collab.stop" = "Завершить совместную сессию" +"command.batch" = "Выполнить несколько команд" +"comment.add" = "Добавить комментарий" +"comment.delete" = "Удалить комментарий" +"comment.edit" = "Изменить комментарий" +"comment.link" = "Копировать ссылку на комментарий" +"comment.list" = "Список комментариев" +"comment.markRead" = "Отметить комментарий как прочитанный" +"comment.move" = "Переместить комментарий" +"comment.next" = "Следующий комментарий" +"comment.open" = "Открыть комментарий" +"comment.people" = "Участники комментариев" +"comment.react" = "Добавить реакцию на комментарий" +"comment.reply" = "Ответить на комментарий" +"comment.resolve" = "Отметить комментарий как решённый" +"comment.zoom" = "Масштаб по комментариям" +"component.addProperty" = "Добавить свойство компонента" +"component.addVariant" = "Добавить вариант" +"component.bindProperty" = "Привязать слой к свойству" +"component.combineVariants" = "Объединить в варианты" +"component.create" = "Создать компонент" +"component.createMultiple" = "Создать несколько компонентов" +"component.deleteProperty" = "Удалить свойство компонента" +"component.detach" = "Отсоединить экземпляр" +"component.editProperty" = "Изменить свойство компонента" +"component.exposeInstance" = "Открыть доступ к вложенному экземпляру" +"component.goToMain" = "Перейти к основному компоненту" +"component.hideWhenPublishing" = "Скрывать при публикации" +"component.list" = "Список компонентов" +"component.makeSlot" = "Сделать слотом" +"component.playground" = "Песочница компонента" +"component.properties" = "Свойства компонента" +"component.pushChanges" = "Перенести изменения в основной компонент" +"component.removeSlot" = "Убрать слот" +"component.resetInstance" = "Сбросить все изменения" +"component.resetOverride" = "Сбросить переопределение" +"component.restoreMain" = "Восстановить основной компонент" +"dev.compare" = "Сравнить изменения" +"dev.focus" = "Режим фокуса" +"dev.resolveChange" = "Разрешить изменение" +"dev.unfocus" = "Выйти из режима фокуса" +"devResource.add" = "Добавить ресурс для разработки" +"devResource.edit" = "Изменить ресурс для разработки" +"devResource.list" = "Список ресурсов для разработки" +"devResource.remove" = "Удалить ресурс для разработки" +"distribute.horizontal" = "Распределить по горизонтали" +"distribute.vertical" = "Распределить по вертикали" +"doc.home" = "Главная" +"doc.list" = "Список документов" +"doc.nextTab" = "Следующая вкладка" +"doc.previousTab" = "Предыдущая вкладка" +"doc.switch" = "Переключить документ" +"edit.copy" = "Копировать" +"edit.copyAsCss" = "Копировать как CSS" +"edit.copyAsPng" = "Копировать как PNG" +"edit.copyAsSvg" = "Копировать как SVG" +"edit.copyAsText" = "Копировать как текст" +"edit.copyLink" = "Копировать ссылку на выделение" +"edit.copyProperties" = "Копировать свойства" +"edit.cut" = "Вырезать" +"edit.delete" = "Удалить" +"edit.duplicate" = "Дублировать" +"edit.findReplace" = "Найти и заменить" +"edit.paste" = "Вставить" +"edit.pasteOver" = "Вставить поверх выделения" +"edit.pasteProperties" = "Вставить свойства" +"edit.pasteReplace" = "Вставить с заменой" +"edit.pickColor" = "Пипетка" +"edit.redo" = "Повторить" +"edit.repeat" = "Повторить последнюю команду" +"edit.setDefaultProperties" = "Задать свойства по умолчанию" +"edit.undo" = "Отменить" +"effect.add" = "Добавить эффект" +"effect.move" = "Переместить эффект" +"effect.remove" = "Удалить эффект" +"effect.set" = "Задать эффект" +"export.add" = "Добавить настройку экспорта" +"export.remove" = "Удалить настройку экспорта" +"export.update" = "Изменить настройку экспорта" +"file.close" = "Закрыть вкладку" +"file.colorProfile" = "Цветовой профиль" +"file.duplicate" = "Дублировать файл" +"file.export" = "Экспорт" +"file.exportFramesPdf" = "Экспортировать Frame в PDF" +"file.exportLayers" = "Экспортировать слои" +"file.exportSvg" = "Экспортировать выделение в SVG" +"file.import" = "Импорт" +"file.new" = "Новый файл дизайна" +"file.newFromTemplate" = "Создать из шаблона" +"file.open" = "Открыть" +"file.openRecent" = "Открыть недавние" +"file.placeImage" = "Разместить изображение" +"file.recover" = "Восстановить файл" +"file.rename" = "Переименовать файл" +"file.revert" = "Вернуть сохранённое состояние" +"file.save" = "Сохранить" +"file.saveAs" = "Сохранить как" +"file.saveVersion" = "Сохранить в историю версий" +"file.versionHistory" = "Показать историю версий" +"fill.add" = "Добавить заливку" +"fill.move" = "Переместить заливку" +"fill.pattern" = "Заливка паттерном" +"fill.remove" = "Удалить заливку" +"fill.set" = "Задать заливку" +"frame.create" = "Создать Frame" +"frame.presets" = "Список пресетов Frame" +"frame.swapOrientation" = "Сменить ориентацию" +"grid.add" = "Добавить сетку макета" +"grid.move" = "Переместить сетку макета" +"grid.remove" = "Удалить сетку макета" +"guide.add" = "Добавить направляющую" +"guide.clear" = "Удалить все направляющие" +"guide.move" = "Переместить направляющую" +"guide.remove" = "Удалить направляющую" +"guide.select" = "Выделить направляющую" +"help.gettingStarted" = "Начало работы" +"help.about" = "О FigCraft" +"help.page" = "Страница справки" +"help.shortcuts" = "Сочетания клавиш" +"image.adjust" = "Коррекция изображения" +"image.deletePreset" = "Удалить пресет изображения" +"image.place" = "Разместить изображение" +"image.presets" = "Пресеты изображения" +"image.savePreset" = "Сохранить пресет изображения" +"inspect.assets" = "Инспектор ресурсов" +"inspect.code" = "Код слоя" +"inspect.componentCode" = "Код компонента" +"inspect.properties" = "Инспектор свойств" +"inspect.readyForDev" = "Готово к разработке" +"instance.create" = "Создать экземпляр" +"instance.exposed" = "Доступные вложенные экземпляры" +"instance.place" = "Разместить экземпляр" +"instance.resetSlot" = "Сбросить слот" +"instance.setProperties" = "Задать свойства экземпляра" +"instance.slotInsert" = "Вставить в слот" +"instance.swap" = "Заменить экземпляр" +"instance.swapAt" = "Заменить экземпляр в точке" +"layers.collapseAll" = "Свернуть слои" +"layout.add" = "Добавить автолейаут" +"layout.remove" = "Убрать автолейаут" +"layout.setTrack" = "Задать размер дорожки сетки" +"layout.suggest" = "Предложить автолейаут" +"libraries.open" = "Библиотеки" +"libraries.publish" = "Опубликовать библиотеку…" +"library.acceptUpdates" = "Принять обновления библиотеки" +"library.add" = "Добавить библиотеку" +"library.aliasVariable" = "Создать алиас переменной библиотеки" +"library.applyStyle" = "Применить стиль библиотеки" +"library.bindVariable" = "Привязать переменную библиотеки" +"library.extendCollection" = "Расширить коллекцию библиотеки" +"library.import" = "Импортировать компонент библиотеки" +"library.importVariable" = "Импортировать переменную библиотеки" +"library.list" = "Список библиотек" +"library.place" = "Разместить компонент библиотеки" +"library.publish" = "Опубликовать библиотеку" +"library.publishToServer" = "Опубликовать библиотеку на сервере" +"library.remove" = "Удалить библиотеку" +"library.setVariableMode" = "Задать режим переменных библиотеки" +"library.swap" = "Заменить библиотеку" +"library.swapAt" = "Заменить экземпляр компонентом библиотеки" +"library.updates" = "Обновления библиотеки" +"library.usage" = "Использование библиотеки" +"measure.distances" = "Измерить расстояния" +"measure.padding" = "Измерить внутренние отступы автолейаута" +"node.create" = "Создать слой" +"node.get" = "Получить слой" +"node.reorder" = "Изменить порядок слоя" +"node.reparent" = "Переместить слой в" +"node.set" = "Задать свойства" +"object.batchRename" = "Массовое переименование" +"object.convertToFrame" = "Преобразовать во Frame" +"object.convertToGroup" = "Преобразовать в группу" +"object.flipH" = "Отразить по горизонтали" +"object.flipV" = "Отразить по вертикали" +"object.frameSelection" = "Поместить выделение во Frame" +"object.group" = "Сгруппировать выделение" +"object.hideOthers" = "Скрыть другие слои" +"object.mask" = "Использовать как маску" +"object.moveToPage" = "Переместить на страницу" +"object.opacityKeys" = "Непрозрачность цифровыми клавишами" +"object.rasterize" = "Растрировать выделение" +"object.releaseMask" = "Снять маску" +"object.removeFill" = "Удалить заливку" +"object.removeFrame" = "Убрать Frame" +"object.removeStroke" = "Удалить обводку" +"object.rename" = "Переименовать" +"object.resizeToFit" = "Подогнать размер" +"object.rotate180" = "Повернуть на 180°" +"object.rotateLeft" = "Повернуть на 90° влево" +"object.rotateRight" = "Повернуть на 90° вправо" +"object.setThumbnail" = "Использовать как миниатюру" +"object.swapFillStroke" = "Поменять заливку и обводку" +"object.toggleLock" = "Заблокировать/разблокировать выделение" +"object.toggleVisible" = "Показать/скрыть выделение" +"object.ungroup" = "Разгруппировать выделение" +"object.wrapInSection" = "Поместить в новую секцию" +"page.add" = "Добавить страницу" +"page.delete" = "Удалить страницу" +"page.duplicate" = "Дублировать страницу" +"page.list" = "Список страниц" +"page.move" = "Переместить страницу" +"page.rename" = "Переименовать страницу" +"page.select" = "Перейти к странице" +"page.setBackground" = "Фон страницы" +"paint.documentColors" = "Цвета документа" +"plugin.close" = "Закрыть панель плагина" +"plugin.directory.install" = "Установить плагин из каталога" +"plugin.directory.list" = "Обзор каталога плагинов" +"plugin.directory.publish" = "Опубликовать плагин в каталоге" +"plugin.directory.unpublish" = "Снять плагин с публикации в каталоге" +"plugin.event" = "Использовать элемент панели плагина" +"plugin.import" = "Импортировать плагин из манифеста…" +"plugin.list" = "Список плагинов" +"plugin.remove" = "Удалить плагин" +"plugin.run" = "Запустить плагин" +"plugin.ui" = "Панель плагина" +"plugins.manage" = "Управление плагинами…" +"plugins.runLast" = "Запустить последний плагин" +"pref.highlightHover" = "Подсвечивать слои при наведении" +"pref.keepTool" = "Оставлять инструмент выбранным после использования" +"pref.language" = "Язык интерфейса" +"pref.nudge" = "Шаг сдвига" +"pref.numberKeysOpacity" = "Задавать непрозрачность цифровыми клавишами" +"pref.publishServer" = "Сервер для онлайн-публикации" +"pref.reduceMotion" = "Уменьшить движение" +"pref.renameDuplicates" = "Переименовывать дубликаты слоёв" +"pref.showDimensions" = "Показать размеры объектов" +"pref.userName" = "Ваше имя в комментариях" +"proto.action.add" = "Добавить действие прототипа" +"proto.action.move" = "Изменить порядок действия прототипа" +"proto.action.remove" = "Удалить действие прототипа" +"proto.action.set" = "Изменить действие прототипа" +"proto.closePreview" = "Закрыть предпросмотр прототипа" +"proto.conditional.addBlock" = "Добавить «Иначе если» / «Иначе»" +"proto.conditional.removeBlock" = "Удалить «Если» / «Иначе если» / «Иначе»" +"proto.conditional.setCondition" = "Изменить условие" +"proto.evaluate" = "Вычислить выражение прототипа" +"proto.flow.add" = "Добавить начальную точку сценария" +"proto.flow.remove" = "Удалить сценарий" +"proto.flow.rename" = "Переименовать сценарий" +"proto.interaction.add" = "Добавить взаимодействие" +"proto.interaction.clear" = "Удалить все взаимодействия" +"proto.interaction.remove" = "Удалить взаимодействие" +"proto.interaction.set" = "Изменить взаимодействие" +"proto.present" = "Презентация" +"proto.presentOptions" = "Параметры презентации" +"proto.preview" = "Открыть предпросмотр прототипа" +"proto.settings" = "Настройки прототипа" +"section.toggleContents" = "Скрыть/показать содержимое секции" +"select.add" = "Добавить к выделению" +"select.all" = "Выделить всё" +"select.area" = "Выделить область" +"select.byName" = "Выделить слои по имени" +"select.children" = "Выделить дочерние слои" +"select.inverse" = "Инвертировать выделение" +"select.layersAt" = "Слои в точке" +"select.matching" = "Выделить соответствующие слои" +"select.next" = "Выделить следующий соседний слой" +"select.none" = "Снять выделение" +"select.parent" = "Выделить родителя" +"select.previous" = "Выделить предыдущий соседний слой" +"select.remove" = "Убрать из выделения" +"select.sameEffect" = "Выделить с одинаковым эффектом" +"select.sameFill" = "Выделить с одинаковой заливкой" +"select.sameFont" = "Выделить с одинаковым шрифтом" +"select.sameInstance" = "Выделить одинаковые экземпляры" +"select.sameProperties" = "Выделить с одинаковыми свойствами" +"select.sameStroke" = "Выделить с одинаковой обводкой" +"select.sameText" = "Выделить с одинаковыми свойствами текста" +"select.set" = "Выделить" +"shape.arrow" = "Стрелка" +"shape.ellipse" = "Эллипс" +"shape.line" = "Линия" +"shape.polygon" = "Многоугольник" +"shape.rectangle" = "Прямоугольник" +"shape.star" = "Звезда" +"shapeBuilder.merge" = "Объединить области" +"shapeBuilder.mode" = "Режим конструктора фигур" +"shapeBuilder.regions" = "Области конструктора фигур" +"shapeBuilder.remove" = "Удалить области" +"stroke.add" = "Добавить обводку" +"stroke.brush" = "Кисть" +"stroke.move" = "Переместить обводку" +"stroke.remove" = "Удалить обводку" +"stroke.set" = "Задать обводку" +"stroke.widthProfile" = "Профиль ширины" +"style.apply" = "Применить стиль" +"style.bind" = "Привязать переменную в стиле" +"style.create" = "Создать стиль" +"style.delete" = "Удалить стиль" +"style.detach" = "Отвязать стиль" +"style.duplicate" = "Дублировать стиль" +"style.list" = "Список стилей" +"style.move" = "Переместить стили" +"style.renameFolder" = "Переименовать папку стилей" +"style.update" = "Изменить стиль" +"text.alignCenter" = "Выровнять по центру" +"text.alignJustify" = "По ширине" +"text.alignLeft" = "Выровнять по левому краю" +"text.alignRight" = "Выровнять по правому краю" +"text.bold" = "Полужирный" +"text.bulletList" = "Маркированный список" +"text.case" = "Регистр букв" +"text.caseLower" = "Строчные" +"text.caseOriginal" = "Исходный регистр" +"text.caseSmallCaps" = "Капитель" +"text.caseTitle" = "Каждое Слово С Прописной" +"text.caseUpper" = "Прописные" +"text.create" = "Текст" +"text.decoration" = "Оформление" +"text.edit.begin" = "Редактировать текст" +"text.edit.caret" = "Установить текстовый курсор" +"text.edit.end" = "Завершить редактирование текста" +"text.edit.insert" = "Ввести текст" +"text.edit.key" = "Клавиша текста" +"text.edit.preedit" = "Составить текст (IME)" +"text.edit.selectLine" = "Выделить строку" +"text.edit.selectWord" = "Выделить слово" +"text.edit.selection" = "Выделение текста" +"text.endPreview" = "Завершить предпросмотр текста" +"text.fonts" = "Список шрифтов" +"text.italic" = "Курсив" +"text.lineHeightDown" = "Уменьшить межстрочный интервал" +"text.lineHeightUp" = "Увеличить межстрочный интервал" +"text.link" = "Создать ссылку" +"text.listIndent" = "Увеличить отступ списка" +"text.listOutdent" = "Уменьшить отступ списка" +"text.listStyle" = "Стиль списка" +"text.maxLines" = "Максимум строк" +"text.numberList" = "Нумерованный список" +"text.onPath" = "Текст на пути" +"text.paragraphIndent" = "Абзацный отступ" +"text.paragraphSpacing" = "Интервал между абзацами" +"text.pathOptions" = "Параметры текста на пути" +"text.preview" = "Предпросмотр изменения текста" +"text.removeFromPath" = "Снять текст с пути" +"text.set" = "Задать текст" +"text.setFeatures" = "Функции OpenType" +"text.setVariations" = "Оси вариативного шрифта" +"text.sizeDown" = "Уменьшить размер шрифта" +"text.sizeUp" = "Увеличить размер шрифта" +"text.strikethrough" = "Зачёркивание" +"text.style" = "Оформить диапазон текста" +"text.trackingDown" = "Уменьшить межбуквенный интервал" +"text.trackingUp" = "Увеличить межбуквенный интервал" +"text.underline" = "Подчёркивание" +"text.weightDown" = "Уменьшить толщину шрифта" +"text.weightUp" = "Увеличить толщину шрифта" +"tool.arrow" = "Стрелка" +"tool.comment" = "Комментарий" +"tool.ellipse" = "Эллипс" +"tool.frame" = "Frame" +"tool.hand" = "Рука" +"tool.line" = "Линия" +"tool.move" = "Перемещение" +"tool.pen" = "Перо" +"tool.pencil" = "Карандаш" +"tool.pencilBrush" = "Кисть карандаша" +"tool.place" = "Разместить выбранным инструментом" +"tool.polygon" = "Многоугольник" +"tool.rectangle" = "Прямоугольник" +"tool.scale" = "Масштаб" +"tool.scaleAnchor" = "Центр масштабирования" +"tool.section" = "Секция" +"tool.set" = "Выбрать инструмент" +"tool.shapeBuilder" = "Конструктор фигур" +"tool.slice" = "Срез" +"tool.star" = "Звезда" +"tool.text" = "Текст" +"transform.move" = "Перемещение" +"transform.moveTo" = "Переместить в" +"transform.nudge" = "Сдвинуть" +"transform.nudgeSize" = "Изменить размер на шаг сдвига" +"transform.resize" = "Изменить размер" +"transform.rotate" = "Повернуть" +"transform.rotationOrigin" = "Переместить центр вращения" +"transform.scale" = "Масштаб" +"transform.setRotationOrigin" = "Задать центр вращения" +"variable.addMode" = "Добавить режим" +"variable.bind" = "Привязать переменную" +"variable.create" = "Создать переменную" +"variable.createCollection" = "Создать коллекцию переменных" +"variable.delete" = "Удалить переменную" +"variable.deleteCollection" = "Удалить коллекцию" +"variable.duplicate" = "Дублировать переменную" +"variable.export" = "Экспортировать переменные" +"variable.import" = "Импортировать переменные" +"variable.list" = "Список переменных" +"variable.moveMode" = "Переместить режим" +"variable.removeMode" = "Удалить режим" +"variable.renameCollection" = "Переименовать коллекцию" +"variable.renameMode" = "Переименовать режим" +"variable.resetOverride" = "Сбросить переопределение переменной" +"variable.set" = "Задать значение переменной" +"variable.setMode" = "Задать режим переменных" +"variable.update" = "Изменить переменную" +"vector.create" = "Вектор" +"vector.deleteHeal" = "Удалить выделение и восстановить путь" +"vector.edit.addVertex" = "Добавить точку" +"vector.edit.begin" = "Редактировать вектор" +"vector.edit.bend" = "Изогнуть сегмент" +"vector.edit.bounds" = "Границы выделенных точек" +"vector.edit.connect" = "Соединить точки" +"vector.edit.cornerRadius" = "Радиус угла точки" +"vector.edit.cut" = "Разрезать путь" +"vector.edit.delete" = "Удалить точки" +"vector.edit.end" = "Завершить редактирование" +"vector.edit.erase" = "Стереть" +"vector.edit.lasso" = "Выделить лассо" +"vector.edit.mirroring" = "Зеркальность направляющих" +"vector.edit.moveHandles" = "Переместить направляющие" +"vector.edit.moveVertices" = "Переместить точки" +"vector.edit.nudge" = "Сдвинуть точки" +"vector.edit.paint" = "Заливка области" +"vector.edit.pen" = "Точка пера" +"vector.edit.penEnd" = "Завершить путь пера" +"vector.edit.position" = "Положение точки" +"vector.edit.resize" = "Изменить размер выделенных точек" +"vector.edit.rotate" = "Повернуть точки" +"vector.edit.select" = "Выделить точки" +"vector.edit.selectAll" = "Выделить все точки" +"vector.edit.setHandle" = "Задать направляющую" +"vector.edit.smooth" = "Сгладить или заострить точки" +"vector.edit.state" = "Состояние редактирования вектора" +"vector.edit.strokeCap" = "Окончание обводки в точке" +"vector.edit.strokeJoin" = "Соединение обводки в точке" +"vector.edit.tool" = "Инструмент редактирования вектора" +"vector.edit.transform" = "Трансформировать точки" +"vector.edit.widthPoint" = "Точка ширины" +"vector.flatten" = "Свести" +"vector.join" = "Соединить выделение" +"vector.offset" = "Сместить путь" +"vector.outlineStroke" = "Преобразовать обводку в контур" +"vector.reverse" = "Обратить направление пути" +"vector.simplify" = "Упростить вектор" +"vector.smoothJoin" = "Плавно соединить выделение" +"version.autosave" = "Автосохранить версию" +"version.delete" = "Удалить версию" +"version.duplicate" = "Дублировать версию" +"version.exitPreview" = "Вернуться к текущей версии" +"version.list" = "Список версий" +"version.preview" = "Просмотреть версию" +"version.rename" = "Переименовать версию" +"version.restore" = "Восстановить версию" +"view.assets" = "Панель ресурсов" +"view.comments" = "Комментарии" +"view.contextMenu" = "Показать контекстное меню" +"view.design" = "Панель дизайна" +"view.designPrototype" = "Переключить дизайн/прототип" +"view.devMode" = "Dev Mode" +"view.frameOutlines" = "Контуры Frame" +"view.get" = "Получить вид" +"view.layers" = "Панель слоёв" +"view.layoutGrids" = "Сетки макета" +"view.leftSidebar" = "Показать/скрыть левую панель" +"view.maskOutlines" = "Контуры масок" +"view.multiplayerCursors" = "Курсоры участников" +"view.nextFrame" = "Масштаб по следующему Frame" +"view.nextPage" = "Следующая страница" +"view.nextRegion" = "Фокус на следующей области" +"view.outlines" = "Контуры" +"view.outlinesHiddenLayers" = "Включить скрытые слои в контуры" +"view.outlinesObjectBounds" = "Включить границы объектов в контуры" +"view.outlinesSelection" = "Контуры выделения" +"view.pan" = "Переместить вид" +"view.panelWidths" = "Сбросить ширину панелей" +"view.pixelGrid" = "Пиксельная сетка" +"view.pixelPreview" = "Пиксельный предпросмотр" +"view.prevFrame" = "Масштаб по предыдущему Frame" +"view.prevPage" = "Предыдущая страница" +"view.previousRegion" = "Фокус на предыдущей области" +"view.proofSrgb" = "Экранная цветопроба sRGB" +"view.fullscreen" = "Полный экран" +"view.prototype" = "Панель прототипа" +"view.rulers" = "Линейки" +"view.set" = "Задать вид" +"view.slices" = "Срезы" +"view.snapGeometry" = "Привязка к геометрии" +"view.snapObjects" = "Привязка к объектам" +"view.snapPixel" = "Привязка к пиксельной сетке" +"view.themeDark" = "Тёмная тема" +"view.themeLight" = "Светлая тема" +"view.themeSystem" = "Системная тема" +"view.toggleUi" = "Показать/скрыть интерфейс" +"view.zoom100" = "Масштаб 100%" +"view.zoom200" = "Масштаб 200%" +"view.zoom50" = "Масштаб 50%" +"view.zoomAt" = "Инструмент масштаба" +"view.zoomFit" = "Вписать всё" +"view.zoomIn" = "Увеличить" +"view.zoomOut" = "Уменьшить" +"view.zoomSelection" = "Масштаб по выделению" +"widget.click" = "Нажать виджет" +"widget.insert" = "Вставить виджет" +"widget.list" = "Список виджетов" +"window.focus" = "Вывести окно на передний план" +"window.new" = "Новое окно" +"template.list" = "Список шаблонов" + +[menu] +"bool.exclude" = "Исключение" +"bool.flatten" = "Свести" +"bool.intersect" = "Пересечение" +"bool.subtract" = "Вычитание" +"bool.union" = "Объединение" +"file.duplicate" = "Дублировать" +"file.import" = "Импортировать из файла…" +"file.rename" = "Переименовать" +"libraries.open" = "Открыть библиотеки" +"select.sameEffect" = "Эффект" +"select.sameFill" = "Заливка" +"select.sameFont" = "Шрифт" +"select.sameInstance" = "Экземпляр" +"select.sameProperties" = "Свойства" +"select.sameStroke" = "Обводка" +"select.sameText" = "Свойства текста" +"text.alignCenter" = "Выровнять текст по центру" +"text.alignJustify" = "Выровнять текст по ширине" +"text.alignLeft" = "Выровнять текст по левому краю" +"text.alignRight" = "Выровнять текст по правому краю" +"view.assets" = "Ресурсы" +"view.design" = "Дизайн" +"view.layers" = "Слои" +"view.outlines" = "Показать контуры" +"view.outlinesHiddenLayers" = "Включить скрытые слои" +"view.outlinesObjectBounds" = "Включить границы объектов" +"view.outlinesSelection" = "Показать контуры выделения" +"view.prototype" = "Прототип" +"view.themeDark" = "Тёмная" +"view.themeLight" = "Светлая" +"view.themeSystem" = "Системная" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml new file mode 100644 index 000000000..a2be6b99c --- /dev/null +++ b/crates/ui-egui/locales/tr.toml @@ -0,0 +1,2181 @@ +# FigCraft interface strings: Turkish. +# +# Machine-drafted from the English catalog and reviewed by a native speaker. Brand names and keyboard +# glyphs stay as they are. Buttons and menu items are bare imperatives (Kaydet, Sil); instructions +# address the user politely (-in/-ın: "seçin", "tıklayın"). The percent sign goes before the number +# (%50). Turkish nouns stay singular after a count, so plural entries give only `other`. See +# docs/translating.md. + +[meta] +name = "Türkçe" + +[ui] +"% of all commits" = "Tüm commit'lerin %'si" +"(no longer published)" = "(artık yayımlanmıyor)" +"({n} warnings: some entries were skipped)" = { other = "({n} uyarı: bazı girdiler atlandı)" } +"+ Create variable" = "+ Değişken oluştur" +".fig (experimental)" = ".fig (deneysel)" +"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} commit, +{added} / −{deleted} satır (Δ {delta}), +{bin_added} / −{bin_deleted} ikili varlık, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Bu dosyanın bu modları ve değişkenleri içeren bir koleksiyonu: değerlerini mod başına geçersiz kılın ve kendi değişkenlerinizi ekleyin" +"A responsive product page: hero, features, figures, a quote and a footer" = "Duyarlı bir ürün sayfası: giriş bölümü, özellikler, rakamlar, bir alıntı ve bir alt bilgi" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Yay, durulana kadar oynar: kütlesi, sertliği ve sönümü ne kadar süreceğini belirler" +"A title, an agenda, a big figure, a chart and a closing slide" = "Bir başlık, bir gündem, büyük bir rakam, bir grafik ve bir kapanış slaydı" +"A to Z" = "A'dan Z'ye" +"About" = "Hakkında" +"About FigCraft" = "FigCraft hakkında" +"About this server" = "Bu sunucu hakkında" +"Action" = "Eylem" +"Actions" = "Eylemler" +"Actions for {name}" = "{name} için eylemler" +"Actions…" = "Eylemler…" +"Active" = "Etkin" +"Actual size (100%)" = "Gerçek boyut (%100)" +"Add a category" = "Kategori ekle" +"Add a comment" = "Yorum ekle" +"Add a description" = "Açıklama ekle" +"Add a frame (F) or a shape (R) to get started" = "Başlamak için bir çerçeve (F) veya bir şekil (R) ekleyin" +"Add a layer or component to tile first" = "Önce döşenecek bir katman veya bileşen ekleyin" +"Add a note for developers" = "Geliştiriciler için not ekle" +"Add action" = "Eylem ekle" +"Add actions for when no condition holds" = "Hiçbir koşul sağlanmadığında yapılacak eylemleri ekleyin" +"Add an annotation for developers" = "Geliştiriciler için ek açıklama ekle" +"Add another condition" = "Başka bir koşul ekle" +"Add auto layout" = "Otomatik yerleşim ekle" +"Add background behind overlay" = "Bindirmenin arkasına arka plan ekle" +"Add content to this slot" = "Bu yuvaya içerik ekle" +"Add dev resource" = "Geliştirici kaynağı ekle" +"Add effect" = "Efekt ekle" +"Add export setting" = "Dışa aktarma ayarı ekle" +"Add fill" = "Dolgu ekle" +"Add interaction" = "Etkileşim ekle" +"Add layout grid" = "Yerleşim ızgarası ekle" +"Add library" = "Kitaplık ekle" +"Add library file…" = "Kitaplık dosyası ekle…" +"Add max height" = "En büyük yükseklik ekle" +"Add max width" = "En büyük genişlik ekle" +"Add min height" = "En küçük yükseklik ekle" +"Add min width" = "En küçük genişlik ekle" +"Add mode" = "Mod ekle" +"Add new page" = "Yeni sayfa ekle" +"Add or update collections from a variables JSON" = "Bir değişken JSON'undan koleksiyon ekleyin veya güncelleyin" +"Add properties to make instances configurable" = "Örnekleri yapılandırılabilir kılmak için özellik ekleyin" +"Add reaction" = "Tepki ekle" +"Add starting point" = "Başlangıç noktası ekle" +"Add stroke" = "Kontur ekle" +"Add variant" = "Varyant ekle" +"Add variant with a new value" = "Yeni değerle varyant ekle" +"Added" = "Eklendi" +"Added the library \"{name}\"" = "“{name}” kitaplığı eklendi" +"Adjust" = "Ayarla" +"Adjustment presets" = "Ayar ön ayarları" +"Advanced" = "Gelişmiş" +"Advanced stroke" = "Gelişmiş kontur" +"After delay" = "Gecikmeden sonra" +"After delay {ms}ms" = "{ms} ms gecikmeden sonra" +"Align" = "Hizala" +"Align bottom" = "Alta hizala" +"Align center" = "Ortaya hizala" +"Align horizontal centers" = "Yatay merkezleri hizala" +"Align left" = "Sola hizala" +"Align middle" = "Dikey ortaya hizala" +"Align right" = "Sağa hizala" +"Align text baselines" = "Metin taban çizgilerini hizala" +"Align top" = "Üste hizala" +"Align vertical centers" = "Dikey merkezleri hizala" +"Alignment" = "Hizalama" +"All" = "Tümü" +"All categories" = "Tüm kategoriler" +"All comments" = "Tüm yorumlar" +"All components" = "Tüm bileşenler" +"All fonts" = "Tüm yazı tipleri" +"All supported properties" = "Desteklenen tüm özellikler" +"All variables" = "Tüm değişkenler" +"Alpha" = "Alfa" +"Alphabetical" = "Alfabetik" +"Alt+drag" = "Alt+sürükle" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Izgara üzerinde bir analiz ekranı: rakamlar, vektörlerden çizilmiş grafikler ve bir tablo" +"An invite link that opens FigCraft and joins" = "FigCraft'ı açıp katılan bir davet bağlantısı" +"Anchor" = "Bağlantı noktası" +"Android" = "Android" +"Angular" = "Açısal" +"Animation" = "Animasyon" +"Annotations" = "Ek açıklamalar" +"Annotations shown on the canvas" = "Tuvalde gösterilen ek açıklamalar" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Başka bir sunucu veya anahtar gerektiren bir sunucu. Olağan sunucu Tercihler'de ayarlanır." +"Any status" = "Herhangi bir durum" +"Anyone on your network with this code can edit the file, while this app stays open." = "Bu uygulama açık kaldığı sürece ağınızda bu koda sahip herkes dosyayı düzenleyebilir." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Düzenleme bağlantısına sahip herkes dosyayı değiştirebilir; görüntüleme bağlantısı yalnızca bakmaya izin verir. Bağlantılar gizli anahtarlarını taşır: onları parola gibi paylaşın. `figcraft-server links` yenilerini oluşturur ve iptal eder." +"Anyone with this code can edit the file." = "Bu koda sahip herkes dosyayı düzenleyebilir." +"Anyone with this code can look at the file, not change it." = "Bu koda sahip herkes dosyaya bakabilir, ancak değiştiremez." +"Appearance" = "Görünüm" +"Apply a component property" = "Bir bileşen özelliği uygula" +"Apply variable" = "Değişken uygula" +"Apply variable to text" = "Metne değişken uygula" +"Apply variable to visibility" = "Görünürlüğe değişken uygula" +"Apps" = "Uygulamalar" +"Apr" = "Nis" +"Arc" = "Yay" +"Arc ratio" = "Yay oranı" +"Arc start" = "Yay başlangıcı" +"Arc sweep" = "Yay açıklığı" +"Archive" = "Arşivle" +"Archived" = "Arşivlendi" +"Arrange" = "Konumlandır" +"Arrow" = "Ok" +"As typed" = "Yazıldığı gibi" +"Asking the server…" = "Sunucuya soruluyor…" +"Assets" = "Varlıklar" +"Aug" = "Ağu" +"Auto" = "Otomatik" +"Auto (space around)" = "Otomatik (etrafında boşluk)" +"Auto (space between)" = "Otomatik (aralarında boşluk)" +"Auto (space evenly)" = "Otomatik (eşit boşluk)" +"Auto ({mode})" = "Otomatik ({mode})" +"Auto columns" = "Otomatik sütunlar" +"Auto gap" = "Otomatik aralık" +"Auto height" = "Otomatik yükseklik" +"Auto layout" = "Otomatik yerleşim" +"Auto rows" = "Otomatik satırlar" +"Auto width" = "Otomatik genişlik" +"Back to the FigCraft server" = "FigCraft sunucusuna dön" +"Back to the font's value" = "Yazı tipinin değerine dön" +"Back" = "Geri" +"Back to Ready for dev" = "Geliştirmeye hazır durumuna dön" +"Back to Ready for dev: the next change compares with how it is now" = "Geliştirmeye hazır durumuna dön: sonraki değişiklik şimdiki hâliyle karşılaştırılır" +"Back to assets" = "Varlıklara dön" +"Back to current" = "Geçerli sürüme dön" +"Back to styles" = "Stillere dön" +"Back to the canvas from a focused region" = "Odaklanılan bölgeden tuvale dön" +"Back to the current version" = "Geçerli sürüme dön" +"Back to the values the font style implies" = "Yazı tipi stilinin öngördüğü değerlere dön" +"Background" = "Arka plan" +"Background blur" = "Arka plan bulanıklığı" +"Basics" = "Temel bilgiler" +"Before" = "Önce" +"Bend" = "Bük" +"Bevel" = "Eğimli" +"Binary assets added" = "Eklenen ikili varlıklar" +"Binary assets removed" = "Kaldırılan ikili varlıklar" +"Black & white" = "Siyah beyaz" +"Bleed" = "Taşma payı" +"Bleed marks" = "Taşma payı işaretleri" +"Bleed marks need a bleed" = "Taşma payı işaretleri için taşma payı gerekir" +"Blend" = "Karışım" +"Blend mode" = "Karışım modu" +"Blue" = "Mavi" +"Blur" = "Bulanıklık" +"Boolean" = "Boolean" +"Boolean groups" = "Boolean grupları" +"Both directions" = "İki yönde" +"Bottom" = "Alt" +"Bottom left radius" = "Sol alt yarıçap" +"Bottom right radius" = "Sağ alt yarıçap" +"Branch" = "Dal" +"Branch archived" = "Dal arşivlendi" +"Branch merged" = "Dal birleştirildi" +"Branch name" = "Dal adı" +"Branch of {file}" = "{file} dalı" +"Branch of {file} · merged or archived" = "{file} dalı · birleştirildi veya arşivlendi" +"Branch updated from main" = "Dal ana dosyadan güncellendi" +"Branch {name}" = "Dal: {name}" +"Branches" = "Dallar" +"Bound to {name}. Click to detach the variable." = "{name} değişkenine bağlı. Değişkeni ayırmak için tıklayın." +"Bring forward" = "Öne getir" +"Bring the main file's changes into the branch" = "Ana dosyanın değişikliklerini dala getir" +"Bring to front" = "En öne getir" +"Bristle" = "Kıl" +"Browse" = "Gözat" +"Brush" = "Fırça" +"Brush rotation jitter" = "Fırça dönüş titreşimi" +"Brush scatter" = "Fırça saçılması" +"Brush size jitter" = "Fırça boyut titreşimi" +"Brush spacing" = "Fırça aralığı" +"Bulleted list" = "Madde işaretli liste" +"C or Shift+C" = "C veya Shift+C" +"C or ⇧C" = "C veya ⇧C" +"CMYK colour" = "CMYK rengi" +"Can view" = "Görüntüleyebilir" +"Checking the server…" = "Sunucu denetleniyor…" +"Clear from this list" = "Bu listeden kaldır" +"Code or invite link" = "Kod veya davet bağlantısı" +"Color profile: {name}" = "Renk profili: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Renk ve metin stilleri, Açık ve Koyu değişkenler, düğmeler, giriş alanları ve kartlar" +"CSS custom properties…" = "CSS özel özellikleri…" +"Can edit" = "Düzenleyebilir" +"Can't read this library: {e}" = "Bu kitaplık okunamıyor: {e}" +"Cancel" = "İptal" +"Canvas" = "Tuval" +"Canvas stacking" = "Tuval yığınlaması" +"Cap height to baseline" = "Büyük harf yüksekliğinden taban çizgisine" +"Case" = "Harf durumu" +"Case-sensitive forms" = "Büyük/küçük harfe duyarlı biçimler" +"Categories" = "Kategoriler" +"Center" = "Orta" +"Change alias" = "Takma adı değiştir" +"Change resolved" = "Değişiklik çözüldü" +"Change style" = "Stili değiştir" +"Change to" = "Şuna değiştir" +"Change to needs a layer inside a variant" = "“Şuna değiştir” için bir varyantın içinde katman gerekir" +"Changed" = "Değiştirildi" +"Changed on both sides: keep one" = "İki tarafta da değiştirildi: birini koruyun" +"Changes on main" = "Ana dosyadaki değişiklikler" +"Changes on the branch" = "Daldaki değişiklikler" +"Changes unknown" = "Değişiklikler bilinmiyor" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Bu bileşeni koduna bağlamak için yukarıdan React, Leptos, SwiftUI veya Compose seçin." +"Choose another profile to change it." = "Değiştirmek için başka bir profil seçin." +"Choose collection" = "Koleksiyon seç" +"Choose image" = "Görüntü seç" +"Choose mode" = "Mod seç" +"Choose variable" = "Değişken seç" +"Circle" = "Daire" +"Clear recent" = "Son kullanılanları temizle" +"Click + to replace mixed content" = "Karışık içeriği değiştirmek için + simgesine tıklayın" +"Click anywhere on the canvas to leave one." = "Yorum bırakmak için tuvalde herhangi bir yere tıklayın." +"Click or drag to merge · {alt} removes" = "Birleştirmek için tıklayın veya sürükleyin · {alt} kaldırır" +"Click or drag to remove · {alt} merges" = "Kaldırmak için tıklayın veya sürükleyin · {alt} birleştirir" +"Click to record a key" = "Tuş kaydetmek için tıklayın" +"Clip content" = "İçeriği kırp" +"Clip to shape" = "Şekle göre kırp" +"Close" = "Kapat" +"Close overlay" = "Bindirmeyi kapat" +"Close preview" = "Önizlemeyi kapat" +"Close tab" = "Sekmeyi kapat" +"Close version history" = "Sürüm geçmişini kapat" +"Close when clicking outside" = "Dışarı tıklanınca kapat" +"Close without saving" = "Kaydetmeden kapat" +"Code" = "Kod" +"Code syntax" = "Kod söz dizimi" +"Collapse" = "Daralt" +"Collapse panel" = "Paneli daralt" +"Collection" = "Koleksiyon" +"Collections" = "Koleksiyonlar" +"Color" = "Renk" +"Color burn" = "Renk yanması" +"Color dodge" = "Renk soldurma" +"Color format" = "Renk biçimi" +"Color profile" = "Renk profili" +"Color styles" = "Renk stilleri" +"Color values in this file are in this color space." = "Bu dosyadaki renk değerleri bu renk uzayındadır." +"Color variables" = "Renk değişkenleri" +"Colour" = "Renk" +"Column" = "Sütun" +"Column gap" = "Sütun aralığı" +"Column sizes" = "Sütun boyutları" +"Column span" = "Sütun yayılımı" +"Columns" = "Sütunlar" +"Columns {count}" = "Sütunlar {count}" +"Comment" = "Yorum" +"Comments" = "Yorumlar" +"Commits" = "Commit'ler" +"Company" = "Şirket" +"Compare changes" = "Değişiklikleri karşılaştır" +"Completed" = "Tamamlandı" +"Component" = "Bileşen" +"Component name" = "Bileşen adı" +"Component property" = "Bileşen özelliği" +"Component set" = "Bileşen kümesi" +"Component sets" = "Bileşen kümeleri" +"Components" = "Bileşenler" +"Conditional" = "Koşullu" +"Confetti" = "Konfeti" +"Conflicts" = "Çakışmalar" +"Connect to code" = "Koda bağla" +"Connected" = "Bağlandı" +"Connected to code: {from}" = "Koda bağlı: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Bağlantı koptu, yeniden bağlanılıyor (deneme {attempt})…" +"Constrain proportions" = "Oranları koru" +"Constraints" = "Kısıtlamalar" +"Content" = "İçerik" +"Contents hidden" = "İçerik gizli" +"Contents only" = "Yalnızca içerik" +"Contextual alternates" = "Bağlamsal alternatifler" +"Contrast" = "Kontrast" +"Contributors" = "Katkıda bulunanlar" +"Contributors: {people} · Commits: {commits}" = "Katkıda bulunanlar: {people} · Commit'ler: {commits}" +"Controller" = "Oyun kumandası" +"Cool" = "Soğuk" +"Copied as CSS" = "CSS olarak kopyalandı" +"Copied as PNG" = "PNG olarak kopyalandı" +"Copied as SVG" = "SVG olarak kopyalandı" +"Copied as text" = "Metin olarak kopyalandı" +"Copied {value}" = "Kopyalandı: {value}" +"Copy" = "Kopyala" +"Copy as" = "Farklı kopyala" +"Copy as CSS" = "CSS olarak kopyala" +"Copy as PNG" = "PNG olarak kopyala" +"Copy as SVG" = "SVG olarak kopyala" +"Copy code" = "Kodu kopyala" +"Copy link" = "Bağlantıyı kopyala" +"Copy the {label}" = "Kopyala: {label}" +"Copy {value}" = "Kopyala: {value}" +"Corner radius" = "Köşe yarıçapı" +"Corner smoothing" = "Köşe yumuşatma" +"Couldn't reach {server}: showing what this computer remembers." = "{server} sunucusuna ulaşılamadı: bu bilgisayarın hatırladıkları gösteriliyor." +"Count" = "Sayı" +"Create" = "Oluştur" +"Create a variable collection first" = "Önce bir değişken koleksiyonu oluşturun" +"Create a variable first" = "Önce bir değişken oluşturun" +"Create alias" = "Takma ad oluştur" +"Create branch" = "Dal oluştur" +"Create collection" = "Koleksiyon oluştur" +"Create component" = "Bileşen oluştur" +"Create component property" = "Bileşen özelliği oluştur" +"Create components, styles or variables to publish them as a library." = "Kitaplık olarak yayımlamak için bileşen, stil veya değişken oluşturun." +"Create property…" = "Özellik oluştur…" +"Create style" = "Stil oluştur" +"Create style from the selected text" = "Seçili metinden stil oluştur" +"Created" = "Oluşturuldu" +"Crop" = "Kırp" +"Crop marks" = "Kesim işaretleri" +"Ctrl+arrows" = "Ctrl+oklar" +"Ctrl+click" = "Ctrl+tıklama" +"Ctrl+scroll" = "Ctrl+kaydırma" +"Current name" = "Geçerli ad" +"Current version" = "Geçerli sürüm" +"Cursor chat" = "İmleç sohbeti" +"Custom" = "Özel" +"Cut" = "Kes" +"Dark" = "Koyu" +"Darken" = "Koyulaştır" +"Dash" = "Kesik çizgi" +"Dashboard" = "Pano" +"Date created" = "Oluşturulma tarihi" +"Dec" = "Ara" +"Decoration style" = "Süsleme stili" +"Decoration" = "Süsleme" +"Deep select" = "Derin seçim" +"Default" = "Varsayılan" +"Default content" = "Varsayılan içerik" +"Default value (the top-left variant)" = "Varsayılan değer (sol üstteki varyant)" +"Delay" = "Gecikme" +"Delete" = "Sil" +"Delete category (its annotations stay)" = "Kategoriyi sil (ek açıklamaları kalır)" +"Delete collection" = "Koleksiyonu sil" +"Delete group" = "Grubu sil" +"Delete mode" = "Modu sil" +"Delete page" = "Sayfayı sil" +"Delete preset" = "Ön ayarı sil" +"Delete property" = "Özelliği sil" +"Delete the style (layers keep their values)" = "Stili sil (katmanlar değerlerini korur)" +"Delete thread" = "Konuyu sil" +"Delete variable" = "Değişkeni sil" +"Delete “{name}”? This can't be undone." = "“{name}” silinsin mi? Bu işlem geri alınamaz." +"Density" = "Yoğunluk" +"Depth" = "Derinlik" +"Describe how and when to use this component" = "Bu bileşenin nasıl ve ne zaman kullanılacağını açıklayın" +"Describe what changed" = "Neyin değiştiğini açıklayın" +"Description" = "Açıklama" +"Description (optional)" = "Açıklama (isteğe bağlı)" +"Design" = "Tasarım" +"Design system starter" = "Tasarım sistemi başlangıç seti" +"Desktop" = "Masaüstü" +"Destination" = "Hedef" +"Detach alias" = "Takma adı ayır" +"Detach instance" = "Örneği ayır" +"Detach property" = "Özelliği ayır" +"Detach style" = "Stili ayır" +"Detach styles" = "Stilleri ayır" +"Detach variable" = "Değişkeni ayır" +"Details" = "Ayrıntılar" +"Dev Mode" = "Geliştirici Modu" +"Dev resources" = "Geliştirici kaynakları" +"Development" = "Geliştirme" +"Device" = "Cihaz" +"Device at 100%" = "Cihaz %100'de" +"Diamond" = "Elmas" +"Difference" = "Fark" +"Dimensions" = "Boyutlar" +"Direction" = "Yön" +"Disconnect from code" = "Kod bağlantısını kes" +"Dispersion" = "Dağılım" +"Display name" = "Görünen ad" +"Dissolve" = "Çözülme" +"Distribute and tidy" = "Dağıt ve düzenle" +"Distribute horizontal spacing" = "Yatay aralığı dağıt" +"Distribute vertical spacing" = "Dikey aralığı dağıt" +"Divider" = "Ayırıcı" +"Don't truncate" = "Kısaltma yapma" +"Done" = "Bitti" +"Done editing" = "Düzenlemeyi bitir" +"Dots" = "Noktalar" +"Dotted" = "Noktalı" +"Down" = "Aşağı" +"Drafts" = "Taslaklar" +"Drag the stroke to add a width point" = "Genişlik noktası eklemek için konturu sürükleyin" +"Draw" = "Çiz" +"Draw a frame" = "Çerçeve çiz" +"Drawing tools" = "Çizim araçları" +"Drop shadow" = "Gölge" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "MIT OR Apache-2.0 çift lisanslıdır. ArtCraft ekibi ve topluluğu tarafından yapılmıştır." +"Duo" = "İkili" +"Duplicate" = "Çoğalt" +"Duplicate by dragging" = "Sürükleyerek çoğalt" +"Duplicate mode" = "Modu çoğalt" +"Duplicate page" = "Sayfayı çoğalt" +"Edit" = "Düzenle" +"Edit as an expression" = "İfade olarak düzenle" +"Edit colour" = "Rengi düzenle" +"Edit comment" = "Yorumu düzenle" +"Edit dev resource" = "Geliştirici kaynağını düzenle" +"Edit link" = "Düzenleme bağlantısı" +"Edit link copied" = "Düzenleme bağlantısı kopyalandı" +"Edit style" = "Stili düzenle" +"Edit variable" = "Değişkeni düzenle" +"Edit with the builder" = "Oluşturucuyla düzenle" +"Edited {when}" = "Düzenlendi: {when}" +"Effect settings" = "Efekt ayarları" +"Effect styles" = "Efekt stilleri" +"Effects" = "Efektler" +"Ellipse" = "Elips" +"Ellipses" = "Elipsler" +"Else" = "Değilse" +"Else if" = "Değilse ve eğer" +"Embedded profile" = "Gömülü profil" +"Empty" = "Boş" +"End" = "Bitiş" +"End point" = "Bitiş noktası" +"Ends the session; everyone keeps their copy" = "Oturumu sonlandırır; herkes kendi kopyasını saklar" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Bir figcraft-server adresi girin ve sunduğu eklentileri görmek için Gözat'a basın." +"Enter to save · Esc to cancel" = "Kaydetmek için Enter · İptal için Esc" +"Eraser" = "Silgi" +"Esc" = "Esc" +"Essential" = "Temel" +"Every conflict has a choice." = "Her çakışma için bir seçim yapıldı." +"Every top-level frame already starts a flow" = "Her üst düzey çerçeve zaten bir akış başlatıyor" +"Exact opacity (4 then 5: 45%)" = "Tam opaklık (4 ardından 5: %45)" +"Exclude" = "Hariç tut" +"Excluded" = "Hariç tutuldu" +"Excluded from layout" = "Yerleşimden hariç" +"Excludes" = "Hariç tutar" +"Exclusion" = "Dışlama" +"Exit focus view (Esc)" = "Odak görünümünden çık (Esc)" +"Expand" = "Genişlet" +"Export" = "Dışa aktar" +"Export all" = "Tümünü dışa aktar" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Çerçeveleri ve katmanları PNG, JPG, SVG veya PDF olarak dışa aktarın" +"Export page as PNG" = "Sayfayı PNG olarak dışa aktar" +"Export settings" = "Dışa aktarma ayarları" +"Export variables" = "Değişkenleri dışa aktar" +"Export {name}" = "Dışa aktar: {name}" +"Export {n} layers" = { other = "{n} katmanı dışa aktar" } +"Exported {file}" = "Dışa aktarıldı: {file}" +"Exported {name}" = "Dışa aktarıldı: {name}" +"Exported {name}; {n} kinds of content were left out" = { other = "Dışa aktarıldı: {name}; {n} tür içerik dışarıda bırakıldı" } +"Exported {n} files" = { other = "{n} dosya dışa aktarıldı" } +"Export…" = "Dışa aktar…" +"Expose properties to instances" = "Özellikleri örneklere aç" +"Exposure" = "Pozlama" +"Expression" = "İfade" +"Extend collection" = "Koleksiyonu genişlet" +"Extends \"{name}\": overridden values are marked" = "“{name}” koleksiyonunu genişletir: geçersiz kılınan değerler işaretlidir" +"Eye" = "Göz" +"F" = "F" +"Fade" = "Solma" +"False" = "Yanlış" +"Feb" = "Şub" +"Fetching from the server…" = "Sunucudan getiriliyor…" +"File" = "Dosya" +"File name" = "Dosya adı" +"File not found" = "Dosya bulunamadı" +"File published" = "Dosya yayımlandı" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Dosyalar uçtan uca şifrelenmez: sunucunun işletmecisi onları okuyabilir." +"Files nobody opens for {n} days are deleted." = { other = "{n} gün boyunca kimsenin açmadığı dosyalar silinir." } +"Fill" = "Dolgu" +"Fill (fr)" = "Doldur (fr)" +"Fill container" = "Kapsayıcıyı doldur" +"Fill window" = "Pencereyi doldur" +"Fills" = "Dolgular" +"Fill|sizing" = "Doldur" +"Filter and sort" = "Filtrele ve sırala" +"Find" = "Bul" +"Find and replace" = "Bul ve değiştir" +"Find layers" = "Katman bul" +"First" = "İlk" +"First commit" = "İlk commit" +"First on top" = "İlki üstte" +"Fit" = "Sığdır" +"Fit device to window" = "Cihazı pencereye sığdır" +"Fit to contents" = "İçeriğe sığdır" +"Fit to window" = "Pencereye sığdır" +"Fit width" = "Genişliğe sığdır" +"Fixed" = "Sabit" +"Fixed gap" = "Sabit aralık" +"Fixed size" = "Sabit boyut" +"Flatten" = "Düzleştir" +"Flip" = "Çevir" +"Flip horizontal" = "Yatay çevir" +"Flip text to the other side" = "Metni diğer tarafa çevir" +"Flip vertical" = "Dikey çevir" +"Flip width profile" = "Genişlik profilini çevir" +"Flow starting point" = "Akış başlangıç noktası" +"Flow starting points" = "Akış başlangıç noktaları" +"Flows" = "Akışlar" +"Focus view" = "Odak görünümü" +"Fold autosaves" = "Otomatik kayıtları katla" +"Folder: click to collapse" = "Klasör: daraltmak için tıklayın" +"Folder: click to expand" = "Klasör: genişletmek için tıklayın" +"Follow" = "Takip et" +"Follow {name}" = "{name} adlı kişiyi takip et" +"Following" = "Takip ediliyor" +"Following {name} · {key} to stop" = "{name} takip ediliyor · durdurmak için {key}" +"Font" = "Yazı tipi" +"Font not installed" = "Yazı tipi yüklü değil" +"Font size" = "Yazı tipi boyutu" +"Font style" = "Yazı tipi stili" +"Forget on this computer" = "Bu bilgisayarda unut" +"Four screens of a trail app with components and a prototype flow" = "Bileşenleri ve bir prototip akışıyla bir doğa yürüyüşü uygulamasının dört ekranı" +"Fractions" = "Kesirler" +"Frame" = "Çerçeve" +"Frame selection" = "Seçimi çerçevele" +"Frame tools" = "Çerçeve araçları" +"Frames" = "Çerçeveler" +"From the library \"{name}\": read-only" = "“{name}” kitaplığından: salt okunur" +"From {name}" = "Kaynak: {name}" +"From {server}" = "Kaynak: {server}" +"Frost" = "Buzlu" +"Gap" = "Aralık" +"Gap between columns" = "Sütunlar arası aralık" +"Gap between items" = "Öğeler arası aralık" +"Gap between rows" = "Satırlar arası aralık" +"Getting started" = "Başlarken" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Herkese açık bir sunucunun adresini ya da bir sunucunun adresini anahtarıyla birlikte verin (`figcraft-server key` anahtarı yazdırır)." +"Glass" = "Cam" +"Go to main component" = "Ana bileşene git" +"Go to {name}" = "{name} öğesine git" +"Grab bag" = "Karışık torba" +"Gradient" = "Gradyan" +"Gray" = "Gri" +"Green" = "Yeşil" +"Greyscale components and two example screens to sketch a flow with" = "Bir akış taslağı çizmek için gri tonlamalı bileşenler ve iki örnek ekran" +"Grid" = "Izgara" +"Grid ({size})" = "Izgara ({size})" +"Grid styles" = "Izgara stilleri" +"Grid {size}" = "Izgara {size}" +"Group" = "Grupla" +"Group selection" = "Seçimi grupla" +"Groups" = "Gruplar" +"Gutter" = "Oluk" +"Hand tool" = "El aracı" +"Hanging list" = "Asılı liste" +"Hanging punctuation" = "Asılı noktalama" +"Hard light" = "Sert ışık" +"Height" = "Yükseklik" +"Help" = "Yardım" +"Help and resources" = "Yardım ve kaynaklar" +"Help page" = "Yardım sayfası" +"Here: {names}" = "Burada: {names}" +"Hidden" = "Gizli" +"Hide contents" = "İçeriği gizle" +"Hide from publishing" = "Yayımlamadan gizle" +"Hide section contents" = "Bölüm içeriğini gizle" +"Hide these tips (Getting started in the Help menu shows them again)" = "Bu ipuçlarını gizle (Yardım menüsündeki Başlarken onları yeniden gösterir)" +"Hide when publishing" = "Yayımlarken gizle" +"Highlight layers on hover" = "Üzerine gelince katmanları vurgula" +"Highlights" = "Açık tonlar" +"Hold Alt" = "Alt tuşunu basılı tutun" +"Hold Alt+Z" = "Alt+Z tuşlarını basılı tutun" +"Hold Ctrl" = "Ctrl tuşunu basılı tutun" +"Hold Z" = "Z tuşunu basılı tutun" +"Hold ⌃ or ⌘" = "⌃ veya ⌘ tuşunu basılı tutun" +"Hold ⌥" = "⌥ tuşunu basılı tutun" +"Hold ⌥Z" = "⌥Z tuşlarını basılı tutun" +"Home" = "Ana sayfa" +"Horizontal" = "Yatay" +"Horizontal align" = "Yatay hizalama" +"Horizontal hex" = "Yatay altıgen" +"Horizontal padding" = "Yatay iç boşluk" +"Horizontal spacing" = "Yatay aralık" +"Hosted by {operator}" = "Barındıran: {operator}" +"Hover a layer to see its properties" = "Özelliklerini görmek için bir katmanın üzerine gelin" +"Hover other layers to measure the distance" = "Mesafeyi ölçmek için diğer katmanların üzerine gelin" +"Hue" = "Ton" +"Hug" = "Sar" +"Hug contents" = "İçeriği sar" +"Icon components on a 24 px grid with keylines and a size preview" = "Anahat çizgileri ve boyut önizlemesiyle 24 px ızgara üzerinde simge bileşenleri" +"Icon set starter" = "Simge seti başlangıcı" +"If" = "Eğer" +"If {condition}" = "Eğer {condition}" +"Ignore auto layout (absolute position)" = "Otomatik yerleşimi yok say (mutlak konum)" +"Image" = "Görüntü" +"Import" = "İçe aktar" +"Import path" = "İçe aktarma yolu" +"Import plugin from manifest" = "Eklentiyi manifestten içe aktar" +"Import plugin from manifest…" = "Eklentiyi manifestten içe aktar…" +"Import variables" = "Değişkenleri içe aktar" +"Imported {name}" = "İçe aktarıldı: {name}" +"Imported {n} new variables, updated {updated}" = { other = "{n} yeni değişken içe aktarıldı, {updated} güncellendi" } +"Import…" = "İçe aktar…" +"In an instance, this comes from the main component" = "Bir örnekte bu, ana bileşenden gelir" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "Tarayıcıda, masaüstü uygulamasının barındırdığı oturumlara ve bir FigCraft sunucusundaki dosyalara katılabilirsiniz. https üzerinden açılan bir sayfa diğer bilgisayarlara yalnızca wss:// bağlantılarıyla (TLS arkasındaki bir sunucu) ulaşır." +"Include bounding box" = "Sınırlayıcı kutuyu dahil et" +"Include id attribute" = "id özniteliğini dahil et" +"Included" = "Dahil" +"Included in layout" = "Yerleşime dahil" +"Individual corners" = "Ayrı köşeler" +"Individual strokes" = "Ayrı konturlar" +"Ink" = "Mürekkep" +"Inner shadow" = "İç gölge" +"Inside" = "İç" +"Inspect" = "İncele" +"Inspect the layer under the pointer: size, colours, typography" = "İşaretçinin altındaki katmanı inceleyin: boyut, renkler, tipografi" +"Install plugin" = "Eklenti yükle" +"Install" = "Yükle" +"Installed {name}" = "Yüklendi: {name}" +"Installed" = "Yüklü" +"Installing…" = "Yükleniyor…" +"Instance" = "Örnek" +"Instance properties" = "Örnek özellikleri" +"Instance swap" = "Örnek değiştirme" +"Instance swap (no other component)" = "Örnek değiştirme (başka bileşen yok)" +"Instances" = "Örnekler" +"Instant" = "Anında" +"Interaction" = "Etkileşim" +"Interactions" = "Etkileşimler" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Arayüz tasarımı ve prototipleme. Rust ile sıfırdan yazılmış, ücretsiz, açık kaynaklı, temiz oda yöntemiyle geliştirilmiş bir tasarım aracı; ArtCraft Crafting Apps ailesinin bir parçası." +"Intersect" = "Kesiştir" +"Intersects" = "Kesişir" +"Invite link" = "Davet bağlantısı" +"Invite link copied" = "Davet bağlantısı kopyalandı" +"It asks permission to:" = "Şunlar için izin istiyor:" +"Jan" = "Oca" +"Join" = "Katıl" +"Join code copied" = "Katılma kodu kopyalandı" +"Join code or invite link" = "Katılma kodu veya davet bağlantısı" +"Join live session" = "Canlı oturuma katıl" +"Join us on Discord" = "Discord'da bize katılın" +"Join with a code" = "Kodla katıl" +"Joined" = "Katıldı" +"Joined {file}" = "Katıldınız: {file}" +"Joining…" = "Katılınıyor…" +"Join|stroke" = "Birleşim" +"Jul" = "Tem" +"Jun" = "Haz" +"Just now" = "Az önce" +"Justify" = "İki yana yasla" +"Keep appearance" = "Görünümü koru" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Görünümü koru, renkler aynı görünsün diye her renk değerini {profile} profiline dönüştürür. Değerleri koru, sayıları olduğu gibi bırakır; bu yüzden renkler kayar." +"Keep tool selected after use" = "Kullanımdan sonra aracı seçili tut" +"Keep values" = "Değerleri koru" +"Key/Gamepad" = "Tuş/Oyun kumandası" +"Key/Gamepad {key}" = "Tuş/Oyun kumandası {key}" +"Keyboard shortcuts" = "Klavye kısayolları" +"Kits" = "Setler" +"Landing page" = "Açılış sayfası" +"Language" = "Dil" +"Lasso" = "Kement" +"Last" = "Son" +"Last change: {note}" = "Son değişiklik: {note}" +"Last commit" = "Son commit" +"Last modified" = "Son değiştirilme" +"Last on top" = "Sonuncusu üstte" +"Layer blur" = "Katman bulanıklığı" +"Layer name" = "Katman adı" +"Layers" = "Katmanlar" +"Layers in an instance take their position from the main component" = "Bir örnekteki katmanlar konumlarını ana bileşenden alır" +"Layout" = "Yerleşim" +"Layout grid" = "Yerleşim ızgarası" +"Layout grid settings" = "Yerleşim ızgarası ayarları" +"Layout grids" = "Yerleşim ızgaraları" +"Leave session" = "Oturumdan ayrıl" +"Leaves" = "Yapraklar" +"Left" = "Sol" +"Left & right" = "Sol ve sağ" +"Left side" = "Sol taraf" +"Left side width" = "Sol taraf genişliği" +"Letter case" = "Harf durumu" +"Letter spacing" = "Harf aralığı" +"Libraries" = "Kitaplıklar" +"Libraries in this file" = "Bu dosyadaki kitaplıklar" +"Library updates available" = "Kitaplık güncellemeleri var" +"Library: click to collapse" = "Kitaplık: daraltmak için tıklayın" +"Library: click to expand" = "Kitaplık: genişletmek için tıklayın" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Kitaplık: değişkenleri burada salt okunurdur; onları kitaplık dosyasında düzenleyip yayımlayın" +"Light" = "Açık" +"Lighten" = "Açıklaştır" +"Light|glass" = "Işık" +"Line" = "Çizgi" +"Line arrow" = "Çizgi ok" +"Line delta" = "Satır farkı" +"Line height" = "Satır yüksekliği" +"Linear" = "Doğrusal" +"Lines" = "Satırlar" +"Lines +/−" = "Satırlar +/−" +"Lines added" = "Eklenen satırlar" +"Lines deleted" = "Silinen satırlar" +"Link" = "Bağlantı" +"Link copied" = "Bağlantı kopyalandı" +"Link docs, code or tickets for developers." = "Geliştiriciler için belge, kod veya iş kaydı bağlantısı verin." +"Link frames on the Prototype tab, then present the flow" = "Prototip sekmesinde çerçeveleri bağlayın, sonra akışı sunun" +"Link this component to its code component: instances then show your real snippet with their values." = "Bu bileşeni kod bileşenine bağlayın: örnekler sizin gerçek kod parçanızı kendi değerleriyle gösterir." +"List spacing" = "Liste aralığı" +"List style" = "Liste stili" +"Live session" = "Canlı oturum" +"Live · {people}" = "Canlı · {people}" +"Local components" = "Yerel bileşenler" +"Local network" = "Yerel ağ" +"Local styles" = "Yerel stiller" +"Local variables" = "Yerel değişkenler" +"Lock/Unlock" = "Kilitle/Kilidi aç" +"Locked" = "Kilitli" +"Lowercase" = "Küçük harf" +"Luminance" = "Parlaklık" +"Luminosity" = "Işıklılık" +"Main" = "Ana" +"Main component deleted" = "Ana bileşen silindi" +"Main menu" = "Ana menü" +"Make a component" = "Bileşen yap" +"Make slot" = "Yuva yap" +"Mar" = "Mar" +"Margin" = "Kenar boşluğu" +"Mark all as read" = "Tümünü okundu olarak işaretle" +"Mark as resolved" = "Çözüldü olarak işaretle" +"Mark as unread" = "Okunmadı olarak işaretle" +"Mark as unresolved" = "Çözülmedi olarak işaretle" +"Mask" = "Maske" +"Mass · Stiffness · Damping" = "Kütle · Sertlik · Sönüm" +"Match (empty: the whole name)" = "Eşleşme (boş: adın tamamı)" +"Max H" = "En büyük Y" +"Max W" = "En büyük G" +"Max lines" = "En fazla satır" +"Maximize" = "Ekranı kapla" +"May" = "May" +"Measure to the selection" = "Seçime kadar ölç" +"Merge into main" = "Ana dosyaya birleştir" +"Merge regions" = "Bölgeleri birleştir" +"Merge the branch into the main file (one undo step) and archive it" = "Dalı ana dosyaya birleştir (tek geri alma adımı) ve arşivle" +"Merged" = "Birleştirildi" +"Merged PRs" = "Birleştirilen PR'ler" +"Min H" = "En küçük Y" +"Min W" = "En küçük G" +"Minimize" = "Simge durumuna küçült" +"Mirror angle" = "Açıyı yansıt" +"Mirror angle and length" = "Açıyı ve uzunluğu yansıt" +"Mirroring" = "Yansıtma" +"Missing" = "Eksik" +"Missing layer" = "Eksik katman" +"Missing style" = "Eksik stil" +"Missing variable" = "Eksik değişken" +"Miter" = "Gönye" +"Miter angle" = "Gönye açısı" +"Mixed" = "Karışık" +"Mixed fonts" = "Karışık yazı tipleri" +"Mixed interactions: click + to replace them" = "Karışık etkileşimler: değiştirmek için + simgesine tıklayın" +"Mobile app" = "Mobil uygulama" +"Mode" = "Mod" +"Model" = "Model" +"Models" = "Modeller" +"Mono" = "Tek renk" +"More" = "Daha fazla" +"More instance actions" = "Daha fazla örnek eylemi" +"Mouse down" = "Fare basılı" +"Mouse enter" = "Fare girişi" +"Mouse leave" = "Fare çıkışı" +"Mouse up" = "Fare bırakıldı" +"Move along the toolbar or the layer rows" = "Araç çubuğunda veya katman satırlarında gezinin" +"Move" = "Taşı" +"Move column left" = "Sütunu sola taşı" +"Move column right" = "Sütunu sağa taşı" +"Move down" = "Aşağı taşı" +"Move in" = "İçeri taşı" +"Move out" = "Dışarı taşı" +"Move tools" = "Taşıma araçları" +"Move up" = "Yukarı taşı" +"Moved to {name}" = "Şuraya taşındı: {name}" +"Multi" = "Çoklu" +"Multiply" = "Çarp" +"Muted" = "Soluk" +"Name" = "Ad" +"Name (A–Z)" = "Ad (A–Z)" +"Named instance" = "Adlandırılmış örnek" +"Navigate to" = "Şuraya git" +"Nested instance" = "İç içe örnek" +"New design file" = "Yeni tasarım dosyası" +"Newest first" = "Önce en yeni" +"Next or previous control in the focused region" = "Odaklanılan bölgedeki sonraki veya önceki denetim" +"Next ready for dev" = "Sonraki geliştirmeye hazır" +"Next size option" = "Sonraki boyut seçeneği" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Varlık yok: dışa aktarma ayarı, simge veya görüntü yok. Tasarım sekmesinde dışa aktarma ayarı ekleyin." +"No boolean variables fit here." = "Buraya uyan boolean değişken yok." +"No boolean variables yet. Create them in Local variables." = "Henüz boolean değişken yok. Onları Yerel değişkenler'de oluşturun." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Dal yok. Dal, değişiklikleri denemek, sonra gözden geçirip birleştirmek için dosyanın bir kopyasıdır." +"No category" = "Kategori yok" +"No changes" = "Değişiklik yok" +"No colour variables fit here." = "Buraya uyan renk değişkeni yok." +"No colour variables yet. Create them in Local variables." = "Henüz renk değişkeni yok. Onları Yerel değişkenler'de oluşturun." +"No comments yet" = "Henüz yorum yok" +"No components yet. Select layers and press {keys}." = "Henüz bileşen yok. Katmanları seçip {keys} tuşlarına basın." +"No conflicts." = "Çakışma yok." +"No contributor data was built into this copy." = "Bu kopyaya katkıda bulunan verisi eklenmemiş." +"No fonts match" = "Eşleşen yazı tipi yok" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Burada katılma kodu yok (sunucu katılma kodlarından eski veya bu adres bir koda sığmıyor): bunun yerine bağlantıyı paylaşın." +"No layer changes to show." = "Gösterilecek katman değişikliği yok." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Henüz kitaplık yok. Kitaplık olarak yayımlanmış bir .figcraft dosyası ekleyin veya bir ekip kitaplığının bağlantısını yapıştırın." +"No list" = "Liste yok" +"No matching comments" = "Eşleşen yorum yok" +"No matching components" = "Eşleşen bileşen yok" +"No matching layers" = "Eşleşen katman yok" +"No matching styles" = "Eşleşen stil yok" +"No matching variables" = "Eşleşen değişken yok" +"No mirroring" = "Yansıtma yok" +"No model credits were built into this copy." = "Bu kopyaya model emeği bilgisi eklenmemiş." +"No number variables fit here." = "Buraya uyan sayı değişkeni yok." +"No number variables yet. Create them in Local variables." = "Henüz sayı değişkeni yok. Onları Yerel değişkenler'de oluşturun." +"No paints" = "Boya yok" +"No plugins on {server}." = "{server} üzerinde eklenti yok." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Henüz eklenti yok. Bir geliştirme eklentisini figcraft-plugin.json dosyasından içe aktarın." +"No preferred components" = "Tercih edilen bileşen yok" +"No preset" = "Ön ayar yok" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Henüz kaydedilmiş sürüm yok. İşinizde geri dönebileceğiniz bir noktayı işaretlemek için bir sürüm kaydedin." +"No scrolling" = "Kaydırma yok" +"No shortcuts match" = "Eşleşen kısayol yok" +"No status" = "Durum yok" +"No string variables fit here." = "Buraya uyan metin değişkeni yok." +"No string variables yet. Create them in Local variables." = "Henüz metin değişkeni yok. Onları Yerel değişkenler'de oluşturun." +"No styles yet." = "Henüz stil yok." +"No styles yet. Click + to create one from the selection." = "Henüz stil yok. Seçimden oluşturmak için + simgesine tıklayın." +"No templates match your search" = "Aramanızla eşleşen şablon yok" +"No text styles yet. Click + to create one from the selected text." = "Henüz metin stili yok. Seçili metinden oluşturmak için + simgesine tıklayın." +"No variables fit here." = "Buraya uyan değişken yok." +"No variables yet. Create them in Local variables." = "Henüz değişken yok. Onları Yerel değişkenler'de oluşturun." +"Nobody else is here yet" = "Burada henüz başka kimse yok" +"Noise" = "Gürültü" +"None" = "Yok" +"None." = "Yok." +"Normal" = "Normal" +"Not bound" = "Bağlı değil" +"Not in a live session" = "Canlı oturumda değil" +"Not in this font" = "Bu yazı tipinde yok" +"Not properties, kept as written: {names}" = "Özellik değil, yazıldığı gibi korundu: {names}" +"Not read yet" = "Henüz okunmadı" +"Not shared" = "Paylaşılmadı" +"Note {n}" = "Not {n}" +"Nothing changed since this was marked ready for dev." = "Geliştirmeye hazır olarak işaretlendiğinden beri hiçbir şey değişmedi." +"Nothing here yet. Set a frame's status to list it." = "Burada henüz bir şey yok. Burada listelemek için bir çerçevenin durumunu ayarlayın." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Henüz çevrimiçi bir şey yok. Bir dosyayı çevrimiçi yapmak için Paylaş'ı kullanın, burada listelenir." +"Nothing to export" = "Dışa aktarılacak bir şey yok" +"Nov" = "Kas" +"Now" = "Şimdi" +"Nudge (Shift: big nudge)" = "Kaydır (Shift: büyük kaydırma)" +"Nudge amount" = "Kaydırma miktarı" +"Number" = "Sayı" +"Number ↑" = "Sayı ↑" +"Number ↓" = "Sayı ↓" +"Numbered list" = "Numaralı liste" +"Object" = "Nesne" +"Oct" = "Eki" +"Off" = "Kapalı" +"Offered first when filling the slot (any content is allowed)" = "Yuvayı doldururken önce önerilir (her içeriğe izin verilir)" +"Offered first when swapping the instance (any component is allowed)" = "Örneği değiştirirken önce önerilir (her bileşene izin verilir)" +"Offset" = "Kaydırma" +"Oldest first" = "Önce en eski" +"On click" = "Tıklanınca" +"On drag" = "Sürüklenince" +"On this page" = "Bu sayfada" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Beş yaygın boyutta tek bir duyuru: kare, dikey, hikâye, bağlantı ve afiş" +"Online" = "Çevrimiçi" +"Online files" = "Çevrimiçi dosyalar" +"Online sharing is off: files are shared on the local network only." = "Çevrimiçi paylaşım kapalı: dosyalar yalnızca yerel ağda paylaşılır." +"Only the first changed layers are listed." = "Yalnızca ilk değişen katmanlar listelenir." +"Only the first {n} changed layers are listed." = "Yalnızca ilk {n} değişen katman listelenir." +"Only your threads" = "Yalnızca sizin konularınız" +"Opacity" = "Opaklık" +"Opacity & blend" = "Opaklık ve karışım" +"Opacity 10%…90%, 0: 100%" = "Opaklık %10…%90, 0: %100" +"Open" = "Aç" +"Open .figcraft, .svg or images" = ".figcraft, .svg veya görüntü aç" +"Open branch" = "Dalı aç" +"Open file" = "Dosya aç" +"Open files" = "Dosyaları aç" +"Open in focus view" = "Odak görünümünde aç" +"Open in new window" = "Yeni pencerede aç" +"Open invite links in FigCraft" = "Davet bağlantılarını FigCraft'ta aç" +"Open link" = "Bağlantıyı aç" +"Open link {url}" = "Bağlantıyı aç: {url}" +"Open overlay" = "Bindirmeyi aç" +"Open prototype preview" = "Prototip önizlemesini aç" +"Open variables ({count})" = "Açık değişkenler ({count})" +"Opened “{name}” in a new tab" = "“{name}” yeni sekmede açıldı" +"Open|comments" = "Açık" +"Options" = "Seçenekler" +"Orange" = "Turuncu" +"Order" = "Sıra" +"Other apps may not open every .fig file exported here." = "Diğer uygulamalar burada dışa aktarılan her .fig dosyasını açamayabilir." +"Others lose access and keep their copy; the file stays on the server for you" = "Diğerleri erişimini kaybeder ve kopyalarını saklar; dosya sizin için sunucuda kalır" +"Others type this under Share ▸ Join with a code" = "Diğerleri bunu Paylaş ▸ Kodla katıl altına yazar" +"Outline stroke" = "Konturu ana hatlara dönüştür" +"Outline text" = "Metni ana hatlara dönüştür" +"Outlines" = "Ana hatlar" +"Outside" = "Dış" +"Overflow" = "Taşma" +"Overlay" = "Bindirme" +"Overlay position" = "Bindirme konumu" +"Overridden here (the collection it extends has another value)" = "Burada geçersiz kılındı (genişlettiği koleksiyonda başka bir değer var)" +"Overrides" = "Geçersiz kılmalar" +"Owner token" = "Sahip belirteci" +"Owner token copied" = "Sahip belirteci kopyalandı" +"PDF print options" = "PDF baskı seçenekleri" +"PDF print settings" = "PDF baskı ayarları" +"Padding" = "İç boşluk" +"Page" = "Sayfa" +"Pages" = "Sayfalar" +"Pages: {pages}" = "Sayfalar: {pages}" +"Paint bucket" = "Boya kovası" +"Pan the canvas" = "Tuvali kaydır" +"Pan with nothing selected (Shift: farther)" = "Hiçbir şey seçili değilken kaydır (Shift: daha uzağa)" +"Panels" = "Paneller" +"Paper" = "Kâğıt" +"Paragraph indent" = "Paragraf girintisi" +"Paragraph indent applies to left-aligned text" = "Paragraf girintisi sola hizalı metne uygulanır" +"Paragraph spacing" = "Paragraf aralığı" +"Pass through" = "Geçiş" +"Paste a link (https://…)" = "Bağlantı yapıştırın (https://…)" +"Paste a team library link (a view link)" = "Bir ekip kitaplığı bağlantısı yapıştırın (görüntüleme bağlantısı)" +"Paste here" = "Buraya yapıştır" +"Paste to replace" = "Değiştirmek için yapıştır" +"Pattern" = "Desen" +"Pattern alignment" = "Desen hizalaması" +"Pattern scale" = "Desen ölçeği" +"Pattern source" = "Desen kaynağı" +"Pen" = "Kalem" +"Pencil" = "Kurşun kalem" +"People in this file" = "Bu dosyadaki kişiler" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Kişiler her yerden bir kodla katılır. Siz paylaşırken dosya {server} üzerinde tutulur." +"People on your network join with a code. Nothing goes through a server." = "Ağınızdaki kişiler bir kodla katılır. Hiçbir şey bir sunucudan geçmez." +"Phone" = "Telefon" +"Pick color" = "Renk seç" +"Pick color (I)" = "Renk seç (I)" +"Pick one font family first" = "Önce tek bir yazı tipi ailesi seçin" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Çerçeve aracını seçin, sonra tuvalde sürükleyin veya bir cihaz boyutu seçin" +"Pin a property" = "Bir özelliği sabitle" +"Pinch" = "Sıkıştır" +"Pixel grid" = "Piksel ızgarası" +"Pixel preview 1x" = "Piksel önizlemesi 1x" +"Pixel preview 2x" = "Piksel önizlemesi 2x" +"Place a shape or frame (its tool chosen)" = "Bir şekil veya çerçeve yerleştir (aracı seçiliyken)" +"Place automatically" = "Otomatik yerleştir" +"Place image" = "Görüntü yerleştir" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Eklentiler FigCraft'ın korumalı alanında dosya, ağ veya saat olmadan çalışır. Yalnızca güvendiğiniz eklentileri yükleyin." +"Plugins" = "Eklentiler" +"Plus darker" = "Artı koyu" +"Plus lighter" = "Artı açık" +"Point" = "Nokta" +"Points" = "Noktalar" +"Polygon" = "Çokgen" +"Polygons" = "Çokgenler" +"Port" = "Bağlantı noktası" +"Position" = "Konum" +"Post" = "Gönder" +"Preferences" = "Tercihler" +"Preferred" = "Tercih edilen" +"Present" = "Sun" +"Present or preview" = "Sun veya önizle" +"Present this flow" = "Bu akışı sun" +"Presentation" = "Sunum" +"Presentation slides" = "Sunum slaytları" +"Presenting: comments" = "Sunum: yorumlar" +"Presenting: cycle the size options" = "Sunum: boyut seçenekleri arasında geçiş" +"Presenting: full screen" = "Sunum: tam ekran" +"Presenting: next frame" = "Sunum: sonraki çerçeve" +"Presenting: previous frame" = "Sunum: önceki çerçeve" +"Presenting: restart" = "Sunum: yeniden başlat" +"Preserve scroll position" = "Kaydırma konumunu koru" +"Presets" = "Ön ayarlar" +"Press a key…" = "Bir tuşa basın…" +"Playground" = "Deneme alanı" +"Preview" = "Önizleme" +"Preview this flow" = "Bu akışı önizle" +"Preview this frame" = "Bu çerçeveyi önizle" +"Previous ready for dev" = "Önceki geliştirmeye hazır" +"Progressive" = "Aşamalı" +"Properties" = "Özellikler" +"Prototype" = "Prototip" +"Prototype preview" = "Prototip önizlemesi" +"Prototype settings" = "Prototip ayarları" +"Publish" = "Yayımla" +"Publish changes" = "Değişiklikleri yayımla" +"Publish to another server…" = "Başka bir sunucuya yayımla…" +"Publish to server" = "Sunucuya yayımla" +"Published version {v}" = "{v} sürümü yayımlandı" +"Published version {v} to the server" = "{v} sürümü sunucuya yayımlandı" +"Published version {v}: save the file so other files can use it" = "{v} sürümü yayımlandı: diğer dosyaların kullanabilmesi için dosyayı kaydedin" +"Published · {people}" = "Yayımlandı · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Bir sunucuya yayımlamak için masaüstü uygulaması gerekir; tarayıcıda bir sunucunun dosyalarına katılabilirsiniz" +"Publishing with the server's key." = "Sunucunun anahtarıyla yayımlanıyor." +"Publishing…" = "Yayımlanıyor…" +"Push" = "İt" +"Push changes to main component" = "Değişiklikleri ana bileşene gönder" +"Put text on path" = "Metni yola yerleştir" +"R" = "R" +"Radial" = "Dairesel" +"Radius" = "Yarıçap" +"Ratio" = "Oran" +"Reading the library…" = "Kitaplık okunuyor…" +"Ready for dev" = "Geliştirmeye hazır" +"Real name" = "Gerçek ad" +"Recent" = "Son kullanılan" +"Recent files" = "Son dosyalar" +"Recents" = "Son kullanılanlar" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { other = "Uygulama beklenmedik şekilde kapandıktan sonra kaydedilmemiş {n} dosya kurtarıldı: saklamak için onları kaydedin" } +"Rectangle" = "Dikdörtgen" +"Rectangles" = "Dikdörtgenler" +"Red" = "Kırmızı" +"Reduce motion" = "Hareketi azalt" +"Refraction" = "Kırılma" +"Regular expression" = "Düzenli ifade" +"Remove" = "Kaldır" +"Remove all" = "Tümünü kaldır" +"Remove annotation" = "Ek açıklamayı kaldır" +"Remove auto layout" = "Otomatik yerleşimi kaldır" +"Remove dev resource" = "Geliştirici kaynağını kaldır" +"Remove export setting" = "Dışa aktarma ayarını kaldır" +"Remove from recents" = "Son kullanılanlardan kaldır" +"Remove from server" = "Sunucudan kaldır" +"Remove from server…" = "Sunucudan kaldır…" +"Remove guide" = "Kılavuzu kaldır" +"Remove interaction" = "Etkileşimi kaldır" +"Remove library from this file" = "Kitaplığı bu dosyadan kaldır" +"Remove min and max" = "En küçük ve en büyüğü kaldır" +"Remove min and max height" = "En küçük ve en büyük yüksekliği kaldır" +"Remove min and max width" = "En küçük ve en büyük genişliği kaldır" +"Remove regions" = "Bölgeleri kaldır" +"Remove slot" = "Yuvayı kaldır" +"Remove starting point" = "Başlangıç noktasını kaldır" +"Remove stop" = "Durağı kaldır" +"Remove text from path" = "Metni yoldan kaldır" +"Remove the else and its actions" = "Değilse dalını ve eylemlerini kaldır" +"Remove this condition and its actions" = "Bu koşulu ve eylemlerini kaldır" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Bu dosya sunucudan kaldırılsın mı? Oradan bir daha paylaşılamaz veya açılamaz. Kopyanız burada açık kalır." +"Remove {name} from {server}?" = "{name}, {server} üzerinden kaldırılsın mı?" +"Removed" = "Kaldırıldı" +"Removed from the server" = "Sunucudan kaldırıldı" +"Removed since it was marked" = "İşaretlendiğinden beri kaldırıldı" +"Rename" = "Yeniden adlandır" +"Rename duplicated layers" = "Çoğaltılan katmanları yeniden adlandır" +"Rename folder" = "Klasörü yeniden adlandır" +"Rename group" = "Grubu yeniden adlandır" +"Rename layers" = "Katmanları yeniden adlandır" +"Rename mode" = "Modu yeniden adlandır" +"Rename on the server" = "Sunucuda yeniden adlandır" +"Rename on the server…" = "Sunucuda yeniden adlandır…" +"Rename page" = "Sayfayı yeniden adlandır" +"Rename to" = "Yeni ad" +"Replace all" = "Tümünü değiştir" +"Replace mixed export settings" = "Karışık dışa aktarma ayarlarını değiştir" +"Replace with" = "Şununla değiştir" +"Replace with a new interaction" = "Yeni bir etkileşimle değiştir" +"Reply" = "Yanıtla" +"Report" = "Bildir" +"Report this file…" = "Bu dosyayı bildir…" +"Reset" = "Sıfırla" +"Reset adjustments" = "Ayarlamaları sıfırla" +"Reset all changes" = "Tüm değişiklikleri sıfırla" +"Reset all overrides on this layer" = "Bu katmandaki tüm geçersiz kılmaları sıfırla" +"Reset overrides" = "Geçersiz kılmaları sıfırla" +"Reset slot" = "Yuvayı sıfırla" +"Reset this override" = "Bu geçersiz kılmayı sıfırla" +"Reset to style" = "Stile sıfırla" +"Reset to the inherited value" = "Devralınan değere sıfırla" +"Resize by the nudge amount" = "Kaydırma miktarı kadar yeniden boyutlandır" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Çerçeveleri cihazın ekranına (veya cihaz yoksa pencereye) göre yeniden boyutlandır: kısıtlamaları ve otomatik yerleşimleri tepki verir" +"Resize to fit" = "Sığacak şekilde yeniden boyutlandır" +"Resolve" = "Çöz" +"Resolve change" = "Değişikliği çöz" +"Resolved" = "Çözüldü" +"Resources" = "Kaynaklar" +"Responsive" = "Duyarlı" +"Restart" = "Yeniden başlat" +"Restore" = "Geri yükle" +"Restore main component" = "Ana bileşeni geri yükle" +"Restore this version" = "Bu sürümü geri yükle" +"Restored “{name}”" = "“{name}” geri yüklendi" +"Reverse the order" = "Sırayı ters çevir" +"Reversed triangle" = "Ters üçgen" +"Review and merge" = "Gözden geçir ve birleştir" +"Ribbon" = "Şerit" +"Right" = "Sağ" +"Right side" = "Sağ taraf" +"Right side width" = "Sağ taraf genişliği" +"Rotate 90°" = "90° döndür" +"Rotate 90° right" = "90° sağa döndür" +"Rotate to landscape" = "Yataya döndür" +"Rotate to portrait" = "Dikeye döndür" +"Rotation" = "Döndürme" +"Rotation jitter" = "Döndürme titreşimi" +"Round" = "Yuvarlak" +"Rounded" = "Yuvarlatılmış" +"Row" = "Satır" +"Row gap" = "Satır aralığı" +"Row sizes" = "Satır boyutları" +"Row span" = "Satır yayılımı" +"Rows" = "Satırlar" +"Rows {count}" = "Satırlar {count}" +"Rulers" = "Cetveller" +"Run" = "Çalıştır" +"Run {label}" = "Çalıştır: {label}" +"Search templates" = "Şablonlarda ara" +"See all templates" = "Tüm şablonları gör" +"Select layers and they space themselves as their content changes" = "Katmanları seçin; içerikleri değiştikçe kendi aralıklarını ayarlarlar" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Katmanla aynı" +"Saturation" = "Doygunluk" +"Save" = "Kaydet" +"Save as" = "Farklı kaydet" +"Save as preset…" = "Ön ayar olarak kaydet…" +"Save version {key}" = "Sürümü kaydet {key}" +"Saved" = "Kaydedildi" +"Saved {name}" = "Kaydedildi: {name}" +"Say something" = "Bir şey söyleyin" +"Scale" = "Ölçek" +"Scatter" = "Saçılma" +"Scoping: where pickers offer it" = "Kapsam: seçicilerin onu sunduğu yerler" +"Screen" = "Ekran" +"Scroll behavior" = "Kaydırma davranışı" +"Scroll to" = "Şuraya kaydır" +"Scroll to layer" = "Katmana kaydır" +"Scroll with parent" = "Üst öğeyle birlikte kaydır" +"Search plugins" = "Eklentilerde ara" +"Search" = "Ara" +"Search actions, layers and tools" = "Eylem, katman ve araç ara" +"Search assets" = "Varlıklarda ara" +"Search comments" = "Yorumlarda ara" +"Search fonts" = "Yazı tiplerinde ara" +"Search layers" = "Katmanlarda ara" +"Search shortcuts" = "Kısayollarda ara" +"Section" = "Bölüm" +"Section color" = "Bölüm rengi" +"Sections" = "Bölümler" +"Select a frame and add a starting point to present it as a flow." = "Bir akış olarak sunmak için bir çerçeve seçin ve bir başlangıç noktası ekleyin." +"Select a frame to preview it" = "Önizlemek için bir çerçeve seçin" +"Select a layer to see its properties and code" = "Özelliklerini ve kodunu görmek için bir katman seçin" +"Select all" = "Tümünü seç" +"Select all with same" = "Aynı olanların tümünü seç" +"Select layer" = "Katman seç" +"Select layers to apply the style to" = "Stilin uygulanacağı katmanları seçin" +"Select overlapping shapes" = "Çakışan şekilleri seç" +"Select points to edit their position, radius and mirroring" = "Konumlarını, yarıçaplarını ve yansıtmalarını düzenlemek için noktaları seçin" +"Select slot" = "Yuvayı seç" +"Selection" = "Seçim" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Seçim kutusu, {x}, {y} konumunda {w} × {h}: oklar onu taşır, Shift+oklar veya {cmd}+oklar boyutlandırır, Enter seçer, Escape iptal eder" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Seçim kutusu: oklar onu taşır, Shift+oklar veya {cmd}+oklar boyutlandırır, Enter seçer, Escape iptal eder" +"Send backward" = "Geriye gönder" +"Send report" = "Raporu gönder" +"Send to back" = "En arkaya gönder" +"Sep" = "Eyl" +"Server address (ws://host:47900/)" = "Sunucu adresi (ws://host:47900/)" +"Server address" = "Sunucu adresi" +"Server for online sharing" = "Çevrimiçi paylaşım sunucusu" +"Set as default" = "Varsayılan yap" +"Set variable" = "Değişken ayarla" +"Set variable mode" = "Değişken modunu ayarla" +"Set variable {name}" = "{name} değişkenini ayarla" +"Set {name} to {value}" = "{name} değerini {value} yap" +"Shadows" = "Gölgeler" +"Shape" = "Şekil" +"Shape builder" = "Şekil oluşturucu" +"Shape tools" = "Şekil araçları" +"Share" = "Paylaş" +"Share this file" = "Bu dosyayı paylaş" +"Share with a code" = "Kodla paylaş" +"Share: live session, export" = "Paylaş: canlı oturum, dışa aktarma" +"Shared" = "Paylaşıldı" +"Shared: copy its code from the file's menu" = "Paylaşıldı: kodunu dosyanın menüsünden kopyalayın" +"Sharing a file needs the desktop app; here you can join one." = "Bir dosyayı paylaşmak için masaüstü uygulaması gerekir; burada birine katılabilirsiniz." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Yerel ağda paylaşılıyor (çevrimiçi paylaşım kapalı: Tercihler'de sunucu ayarlanmamış)." +"Sharing on your network · {people}" = "Ağınızda paylaşılıyor · {people}" +"Sharing online · {people}" = "Çevrimiçi paylaşılıyor · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Paylaşım durduruldu: dosya sunucuda kalır, yalnızca size açık" +"Show" = "Göster" +"Show all templates" = "Tüm şablonları göster" +"Show as grid" = "Izgara olarak göster" +"Show as list" = "Liste olarak göster" +"Show autosaves" = "Otomatik kayıtları göster" +"Show behind transparent areas" = "Saydam alanların arkasında göster" +"Show comments (Shift+C)" = "Yorumları göster (Shift+C)" +"Show device frame" = "Cihaz çerçevesini göster" +"Show dimensions on objects" = "Nesnelerde boyutları göster" +"Show hotspot hints" = "Etkin nokta ipuçlarını göster" +"Show in Explorer" = "Explorer'da göster" +"Show in Finder" = "Finder'da göster" +"Show in folder" = "Klasörde göster" +"Show only this on the canvas" = "Tuvalde yalnızca bunu göster" +"Show open" = "Açık olanları göster" +"Show section contents" = "Bölüm içeriğini göster" +"Show this layer" = "Bu katmanı göster" +"Show usage" = "Kullanımı göster" +"Show/Hide" = "Göster/Gizle" +"Show/Hide UI" = "Arayüzü göster/gizle" +"Side by side" = "Yan yana" +"Sidebar" = "Kenar çubuğu" +"Simplify stroke" = "Konturu sadeleştir" +"Size" = "Boyut" +"Size jitter" = "Boyut titreşimi" +"Skip ink" = "Mürekkebi atla" +"Slashed zero" = "Eğik çizgili sıfır" +"Slice" = "Dilim" +"Slices" = "Dilimler" +"Slide in" = "Kayarak gir" +"Slide out" = "Kayarak çık" +"Slot" = "Yuva" +"Slot content" = "Yuva içeriği" +"Small capitals" = "Küçük büyük harfler" +"Small caps" = "Küçük büyük harf" +"Smart animate" = "Akıllı canlandırma" +"Snap to pixel grid" = "Piksel ızgarasına yapıştır" +"Social and slides" = "Sosyal medya ve slaytlar" +"Social media" = "Sosyal medya" +"Social post sizes" = "Sosyal medya gönderi boyutları" +"Soft light" = "Yumuşak ışık" +"Solid" = "Düz" +"Some variants have the same property values: give each one a unique combination" = "Bazı varyantların özellik değerleri aynı: her birine benzersiz bir birleşim verin" +"Sort" = "Sırala" +"Sort by" = "Sıralama ölçütü" +"Sort by date" = "Tarihe göre sırala" +"Sort by page" = "Sayfaya göre sırala" +"Sort by {column}" = "{column} sütununa göre sırala" +"Space around" = "Etrafında boşluk" +"Space between" = "Aralarında boşluk" +"Space drag" = "Boşlukla sürükle" +"Space evenly" = "Eşit boşluk" +"Space+drag" = "Boşluk+sürükle" +"Spacing" = "Aralık" +"Spam, stolen work, abuse…" = "İstenmeyen içerik, çalıntı iş, kötüye kullanım…" +"Splay" = "Açılma" +"Spotlight me" = "Beni öne çıkar" +"Spread" = "Yayılma" +"Square" = "Kare" +"Standard" = "Standart" +"Standard ligatures" = "Standart bitişik harfler" +"Star" = "Yıldız" +"Stars" = "Yıldızlar" +"Start" = "Başlangıç" +"Start ascending from / stop descending at" = "Artan sıralamada buradan başla / azalan sıralamada burada dur" +"Start from a blank canvas" = "Boş bir tuvalle başla" +"Start from a template" = "Bir şablonla başla" +"Start on path" = "Yoldaki başlangıç" +"Start point" = "Başlangıç noktası" +"Start sharing" = "Paylaşmaya başla" +"Start sharing with the Share button, then pass on the code it gives" = "Paylaş düğmesiyle paylaşmaya başlayın, sonra verdiği kodu iletin" +"Starting to share…" = "Paylaşım başlatılıyor…" +"Status" = "Durum" +"Sticky" = "Yapışkan" +"Stop" = "Durdur" +"Stop following {name}" = "{name} takibini bırak" +"Stop session" = "Oturumu durdur" +"Stop sharing" = "Paylaşmayı durdur" +"Stop spotlighting" = "Öne çıkarmayı durdur" +"Stop {n} {hex}" = "Durak {n} {hex}" +"Stretch" = "Uzat" +"Strikethrough" = "Üstü çizili" +"String" = "Metin" +"Stroke" = "Kontur" +"Stroke style" = "Kontur stili" +"Stroke weight" = "Kontur kalınlığı" +"Strokes" = "Konturlar" +"Style" = "Stil" +"Styles" = "Stiller" +"Stylistic set 1" = "Stil seti 1" +"Stylistic set 2" = "Stil seti 2" +"Stylistic set 3" = "Stil seti 3" +"Stylistic set 4" = "Stil seti 4" +"Stylistic set 5" = "Stil seti 5" +"Subtract" = "Çıkar" +"Subtracts" = "Çıkarır" +"Suffix" = "Son ek" +"Suspend snapping while dragging" = "Sürüklerken yapışmayı askıya al" +"Swap instance" = "Örneği değiştir" +"Swap library: use another library's assets of the same names" = "Kitaplığı değiştir: aynı adlara sahip başka bir kitaplığın varlıklarını kullanın" +"Swap orientation" = "Yönü değiştir" +"Swap overlay" = "Bindirmeyi değiştir" +"Swap to {name}" = "{name} ile değiştir" +"Swap width and height" = "Genişlik ve yüksekliği değiştir" +"Swapped instance" = "Değiştirilmiş örnek" +"Swapped {n} assets" = { other = "{n} varlık değiştirildi" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { other = "{n} varlık değiştirildi; {missed} için eşleşme bulunamadı ve hâlâ eski kitaplığı kullanıyor" } +"Sweep" = "Tarama" +"System" = "Sistem" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Tablo" +"Tablet" = "Tablet" +"Tabular numbers" = "Tablo rakamları" +"Take the values from the first selected layer" = "Değerleri ilk seçili katmandan al" +"Taper" = "İncelme" +"Taper both ends" = "İki ucu incelt" +"Tell the server's operator what is wrong with it" = "Sunucunun işletmecisine sorunun ne olduğunu söyleyin" +"Temperature" = "Sıcaklık" +"Template" = "Şablon" +"Templates" = "Şablonlar" +"Terms" = "Koşullar" +"Text" = "Metin" +"Text align" = "Metin hizalaması" +"Text content" = "Metin içeriği" +"Text style" = "Metin stili" +"Text styles" = "Metin stilleri" +"Texts" = "Metinler" +"Texture" = "Doku" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Tuval renkleri sRGB olarak önizler; dışa aktarılan PNG, JPG ve WebP dosyaları Display P3 olarak etiketlenir." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Dosya sunucudan silinir: artık kimse onu oradan açamaz veya ona katılamaz ve bu işlem geri alınamaz. Açık olan kopyalar, kimdeyse onda kalır." +"The file now holds another library" = "Dosya artık başka bir kitaplık içeriyor" +"The file now holds another library (\"{name}\")" = "Dosya artık başka bir kitaplık içeriyor (“{name}”)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Bu bilgisayardan çevrimiçi paylaştığınız veya yayımladığınız dosyalar. Siz kaldırana kadar sunucularında kalırlar." +"The link carries the session's secret: share it only with people you invite." = "Bağlantı oturumun gizli anahtarını taşır: yalnızca davet ettiğiniz kişilerle paylaşın." +"The name in your online files and on the server. The document inside keeps its own name." = "Çevrimiçi dosyalarınızda ve sunucudaki ad. İçerideki belge kendi adını korur." +"The selection uses several text styles: click to apply one" = "Seçim birden fazla metin stili kullanıyor: birini uygulamak için tıklayın" +"The server deletes this file if nobody opens it before {date}." = "{date} tarihinden önce kimse açmazsa sunucu bu dosyayı siler." +"The whole file" = "Dosyanın tamamı" +"Theme" = "Tema" +"There's no destination for that action" = "Bu eylem için bir hedef yok" +"Thickness" = "Kalınlık" +"This branch can't be reviewed: {e}" = "Bu dal gözden geçirilemiyor: {e}" +"This branch was merged or archived." = "Bu dal birleştirildi veya arşivlendi." +"This computer only" = "Yalnızca bu bilgisayar" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Bu bilgisayar sahip belirtecini hatırlar, böylece dosya ana ekrandaki Çevrimiçi listesinden yönetilebilir. Dosyayı başka bir yerden yönetmek için kopyalayın: bu, dosyanın parolasıdır ve bir daha gösterilmez." +"This condition is edited as an expression" = "Bu koşul bir ifade olarak düzenleniyor" +"This file" = "Bu dosya" +"This file has unsaved changes" = "Bu dosyada kaydedilmemiş değişiklikler var" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Bu dosya {server} üzerinde ve yalnızca size açık. Başkalarını yeni bir kodla almak için paylaşın." +"This frame is new on the branch." = "Bu çerçeve dalda yeni." +"This frame isn't on the branch." = "Bu çerçeve dalda yok." +"This server is open to the public: no key needed." = "Bu sunucu herkese açık: anahtar gerekmez." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Bu sunucu anahtarını istiyor: adrese ?key=… ekleyin (`figcraft-server key` anahtarı sunucunun makinesinde yazdırır)." +"This server takes files only with its key: see Advanced." = "Bu sunucu dosyaları yalnızca anahtarıyla kabul ediyor: Gelişmiş'e bakın." +"Tidy up" = "Toparla" +"Tile" = "Döşe" +"Tile type" = "Döşeme türü" +"Tint" = "Renk tonu" +"Title" = "Başlık" +"Title case" = "Başlık biçimi" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Bu kitaplığı ekibinizle paylaşmak için dosyayı bir sunucuya yayımlayın (Paylaş ▸ Sunucuya yayımla…), sonra kitaplığı orada yayımlayın." +"Toggle" = "Aç/Kapat" +"Toggle light/dark theme" = "Açık/koyu temayı değiştir" +"Toggle visibility" = "Görünürlüğü değiştir" +"Tools" = "Araçlar" +"Top" = "Üst" +"Top & bottom" = "Üst ve alt" +"Top left radius" = "Sol üst yarıçap" +"Top right radius" = "Sağ üst yarıçap" +"Transform" = "Dönüştür" +"Triangle arrow" = "Üçgen ok" +"Trigger" = "Tetikleyici" +"True" = "Doğru" +"Truncate text" = "Metni kısalt" +"Truncate with an ellipsis" = "Üç noktayla kısalt" +"Try again" = "Yeniden dene" +"Try another search or filter." = "Başka bir arama veya filtre deneyin." +"Try another word, or look at every kind." = "Başka bir sözcük deneyin veya tüm türlere bakın." +"Turn a layer into a component, reuse it, and change every copy at once" = "Bir katmanı bileşene dönüştürün, yeniden kullanın ve tüm kopyalarını tek seferde değiştirin" +"Two digits" = "İki basamak" +"Type" = "Tür" +"Type settings" = "Yazı ayarları" +"Type the join code you were given, or paste an invite link." = "Size verilen katılma kodunu yazın veya bir davet bağlantısı yapıştırın." +"Typography" = "Tipografi" +"Underline" = "Altı çizili" +"Undo takes back only your own changes; what others changed since stays." = "Geri al yalnızca kendi değişikliklerinizi geri alır; o zamandan beri başkalarının yaptıkları kalır." +"Ungroup" = "Grubu çöz" +"Uniform" = "Tekdüze" +"Union" = "Birleştir" +"Unions" = "Birleşimler" +"Unknown — treated as sRGB" = "Bilinmiyor — sRGB olarak işlenir" +"Unread" = "Okunmamış" +"Unresolve" = "Çözülmedi yap" +"Unsaved changes" = "Kaydedilmemiş değişiklikler" +"Unsaved changes in {names}. Close the window and lose them?" = "{names} içinde kaydedilmemiş değişiklikler var. Pencere kapatılıp bunlar kaybedilsin mi?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin ({save}) veya atmak için sekmeyi {alt}-tıklamayla kapatın" +"Unsaved changes: save first, or close again with {key} to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin veya atmak için {key} ile yeniden kapatın" +"Up" = "Yukarı" +"Update plugin" = "Eklentiyi güncelle" +"Update" = "Güncelle" +"Update from main" = "Ana dosyadan güncelle" +"Update from selection" = "Seçimden güncelle" +"Updated {components} and {styles}" = "{components} ve {styles} güncellendi" +"Updated {name}" = "Güncellendi: {name}" +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Bu dosyayı bir FigCraft sunucusuna yükler ve bu sekmeyi onunla eşitlenmiş tutar. Bir düzenleme ve bir görüntüleme bağlantısı alırsınız." +"Uppercase" = "Büyük harf" +"Use a fixed value" = "Sabit bir değer kullan" +"Use an expression, e.g. count + 1" = "Bir ifade kullanın, ör. count + 1" +"Use as mask" = "Maske olarak kullan" +"Use auto layout" = "Otomatik yerleşim kullan" +"Use number keys for opacity" = "Opaklık için rakam tuşlarını kullan" +"Use the layer's fill" = "Katmanın dolgusunu kullan" +"Used in {files} · {instances}" = "Kullanıldığı yer: {files} · {instances}" +"Username" = "Kullanıcı adı" +"Value" = "Değer" +"Uses variables: each layer shows them in its own mode" = "Değişken kullanır: her katman onları kendi modunda gösterir" +"Variable" = "Değişken" +"Variable collections" = "Değişken koleksiyonları" +"Variable modes" = "Değişken modları" +"Variable width" = "Değişken genişlik" +"Variables" = "Değişkenler" +"Variables JSON…" = "Değişken JSON'u…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Değişkenler yeniden kullanılabilir değerleri (renk, sayı, metin, boolean) Açık ve Koyu gibi modlarla saklar." +"Variant" = "Varyant" +"Vector" = "Vektör" +"Vectors" = "Vektörler" +"Version" = "Sürüm" +"Version history" = "Sürüm geçmişi" +"Version {version}" = "Sürüm {version}" +"Version {v} · {components} · {styles} · {variables}" = "Sürüm {v} · {components} · {styles} · {variables}" +"Vertical" = "Dikey" +"Vertical align" = "Dikey hizalama" +"Vertical hex" = "Dikey altıgen" +"Vertical padding" = "Dikey iç boşluk" +"Vertical spacing" = "Dikey aralık" +"Vertical trim" = "Dikey kırpma" +"View" = "Görünüm" +"View link" = "Görüntüleme bağlantısı" +"View link copied" = "Görüntüleme bağlantısı kopyalandı" +"View only" = "Yalnızca görüntüleme" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Yalnızca görüntüleme: her değişikliği olduğu anda görürsünüz ama düzenleyemezsiniz. Düzenleyebileceğiniz bir kopya saklamak için ayrılın." +"Viewing “{name}”" = "“{name}” görüntüleniyor" +"Violet" = "Mor" +"Visibility" = "Görünürlük" +"Visible" = "Görünür" +"Vivid" = "Canlı" +"Warm" = "Sıcak" +"Watch" = "İzle" +"Wavy" = "Dalgalı" +"Web" = "Web" +"Websites" = "Web siteleri" +"WebSocket URL" = "WebSocket URL'si" +"WebSocket URL copied" = "WebSocket URL'si kopyalandı" +"Wedge" = "Kama" +"Weight" = "Kalınlık" +"What you try here changes this preview and the code below, not the file" = "Burada denedikleriniz dosyayı değil, bu önizlemeyi ve aşağıdaki kodu değiştirir" +"What changed since it was marked ready for dev" = "Geliştirmeye hazır olarak işaretlendiğinden beri neler değişti" +"What is wrong with this file? The server's operator gets your report." = "Bu dosyada sorun ne? Raporunuz sunucunun işletmecisine iletilir." +"What it's for" = "Ne için" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Bunun neden değiştiği bilinmiyor: FigCraft geliştirmeye hazır çerçevelerin bir kopyasını saklamaya başlamadan önce değişti. Bundan sonra karşılaştırmak için değişikliği çözün (veya yeniden hazır olarak işaretleyin)." +"While hovering" = "Üzerine gelindiğinde" +"While pressing" = "Basılıyken" +"White" = "Beyaz" +"Who runs it, its terms and how long it keeps files" = "Kimin işlettiği, koşulları ve dosyaları ne kadar sakladığı" +"Why it changed" = "Neden değişti" +"Widgets" = "Widget'lar" +"Width" = "Genişlik" +"Width point" = "Genişlik noktası" +"Width profile" = "Genişlik profili" +"Wireframe kit" = "Tel kafes seti" +"With the layers' export settings" = "Katmanların dışa aktarma ayarlarıyla" +"Wrap" = "Kaydır" +"Wrap in new section" = "Yeni bölüme sar" +"Yellow" = "Sarı" +"Yesterday" = "Dün" +"You" = "Siz" +"You'll get the shared file as a new tab and edit it together." = "Paylaşılan dosyayı yeni bir sekme olarak alacak ve birlikte düzenleyeceksiniz." +"You're spotlighting: everyone follows your view" = "Öne çıkarıyorsunuz: herkes görünümünüzü takip ediyor" +"Your copy of the file stays open" = "Dosyanın kopyanız açık kalır" +"Your copy stays open; the file stays on the server" = "Kopyanız açık kalır; dosya sunucuda kalır" +"Your name" = "Adınız" +"Your name in comments" = "Yorumlardaki adınız" +"Your system asks for reduced motion: animations stay off." = "Sisteminiz hareketin azaltılmasını istiyor: animasyonlar kapalı kalır." +"Z" = "Z" +"Z to A" = "Z'den A'ya" +"Zoom" = "Yakınlaştırma" +"Zoom around the pointer" = "İşaretçinin çevresinde yakınlaştır" +"Zoom device to fill window" = "Cihazı pencereyi dolduracak şekilde yakınlaştır" +"Zoom in" = "Yakınlaştır" +"Zoom out" = "Uzaklaştır" +"Zoom to 100%" = "%100'e yakınlaştır" +"Zoom to 200%" = "%200'e yakınlaştır" +"Zoom to 50%" = "%50'ye yakınlaştır" +"Zoom to fit" = "Sığdırmak için yakınlaştır" +"Zoom to selection" = "Seçime yakınlaştır" +"Zoom tool: click to zoom in, drag an area" = "Yakınlaştırma aracı: yakınlaştırmak için tıklayın, bir alan sürükleyin" +"Zoom tool: zoom out" = "Yakınlaştırma aracı: uzaklaştır" +"and {n} more" = { other = "ve {n} tane daha" } +"bleed marks" = "taşma payı işaretleri" +"by {author}" = "yazan: {author}" +"call any command its access allows" = "erişiminin izin verdiği her komutu çağırmak" +"call only: {commands}" = "yalnızca şunları çağırmak: {commands}" +"can edit the document" = "belgeyi düzenleyebilir" +"crop marks" = "kesim işaretleri" +"development: {path}" = "geliştirme: {path}" +"e.g. count + 1" = "ör. count + 1" +"e.g. count >= 3 and not done" = "ör. count >= 3 and not done" +"figcraft:// links from a browser, chat or email open this app and join the session." = "Bir tarayıcıdan, sohbetten veya e-postadan gelen figcraft:// bağlantıları bu uygulamayı açar ve oturuma katılır." +"from {server}" = "kaynak: {server}" +"hidden" = "gizli" +"iOS" = "iOS" +"just now" = "az önce" +"locked" = "kilitli" +"no bleed" = "taşma payı yok" +"nobody else here yet" = "burada henüz başka kimse yok" +"now" = "şimdi" +"or" = "veya" +"put a widget on the canvas" = "tuvale bir widget koymak" +"read and edit the document" = "belgeyi okumak ve düzenlemek" +"read the document" = "belgeyi okumak" +"reads the document" = "belgeyi okur" +"the plugin" = "eklenti" +"this session only" = "yalnızca bu oturum" +"yesterday" = "dün" +"{instances} in {files}" = "{instances}, {files} içinde" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: taslak çeviri, ana dili konuşan birinin incelemesini bekliyor." +"{length} bleed" = "{length} taşma payı" +"{month} {day}, {year}" = "{day} {month} {year}" +"{name} cleared from the list" = "{name} listeden kaldırıldı" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} bu bilgisayarda unutuldu; dosya {server} üzerinde kalır" +"{name} is on the server." = "{name} sunucuda." +"{name} is spotlighting" = "{name} öne çıkarıyor" +"{name}\nClick to apply to the selection" = "{name}\nSeçime uygulamak için tıklayın" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nTuvale sürükleyin veya görünümün ortasına yerleştirmek için tıklayın.\nBir örneği değiştirmek için üzerine bırakırken Alt (Option) tuşunu basılı tutun." +"{name} (from {library})" = "{name} ({library} kitaplığından)" +"{name} (you)" = "{name} (siz)" +"{name} ({layers} layers)" = "{name} ({layers} katman)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, varsayılan {default}. Stilin değerine dönmek için kaydırıcıya çift tıklayın; sürüklerken Escape iptal eder." +"{name} from {path}" = "{name}, kaynak: {path}" +"{name} was removed from the server" = "{name} sunucudan kaldırıldı" +"{name} · deleted on main" = "{name} · ana dosyada silindi" +"{name} · deleted on the branch" = "{name} · dalda silindi" +"{name} · not published" = "{name} · yayımlanmadı" +"{name} · on a server" = "{name} · bir sunucuda" +"{name} · published version {v}" = "{name} · yayımlanan sürüm {v}" +"{name} · version {v} on the server" = "{name} · sunucuda sürüm {v}" +"{name}, in a live session" = "{name}, canlı oturumda" +"{name}, online, open only to you" = "{name}, çevrimiçi, yalnızca size açık" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, seçili" +"{n} added" = { other = "{n} eklendi" } +"{n} autosave versions" = { other = "{n} otomatik kayıt sürümü" } +"{n} changed" = { other = "{n} değişti" } +"{n} columns" = { other = "{n} sütun" } +"{n} components" = { other = "{n} bileşen" } +"{n} components and styles have newer versions" = { other = "{n} bileşen ve stilin daha yeni sürümü var" } +"{n} conflicts to resolve" = { other = "Çözülecek {n} çakışma" } +"{n} days ago" = { other = "{n} gün önce" } +"{n} files" = { other = "{n} dosya" } +"{n} hours ago" = { other = "{n} saat önce" } +"{n} instances" = { other = "{n} örnek" } +"{n} layers" = { other = "{n} katman" } +"{n} layers as one file" = { other = "Tek dosya olarak {n} katman" } +"{n} layers selected" = { other = "{n} katman seçili" } +"{n} layers · {parts}" = { other = "{n} katman · {parts}" } +"{n} matches" = { other = "{n} eşleşme" } +"{n} minutes ago" = { other = "{n} dakika önce" } +"{n} months ago" = { other = "{n} ay önce" } +"{n} of {total}" = "{n} / {total}" +"{n} people joined" = { other = "{n} kişi katıldı" } +"{n} preferred components" = { other = "{n} tercih edilen bileşen" } +"{n} ready" = { other = "{n} hazır" } +"{n} removed" = { other = "{n} kaldırıldı" } +"{n} replies" = { other = "{n} yanıt" } +"{n} rows" = { other = "{n} satır" } +"{n} styles" = { other = "{n} stil" } +"{n} variables" = { other = "{n} değişken" } +"{n} weeks ago" = { other = "{n} hafta önce" } +"{n} years ago" = { other = "{n} yıl önce" } +"{n}d" = "{n} g" +"{n}h" = "{n} sa" +"{n}m" = "{n} dk" +"{n}w" = "{n} hf" +"{page}, nothing selected" = "{page}, hiçbir şey seçili değil" +"{profile} (current)" = "{profile} (geçerli)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} onun değeridir; {property ? \"a\" : \"b\"} bir metin seçer. Diğer {…} yazıldığı gibi kalır." +"{server} can't say what became of your files (it is older than this app)." = "{server}, dosyalarınıza ne olduğunu söyleyemiyor (bu uygulamadan eski)." +"{server} · No longer on the server" = "{server} · Artık sunucuda değil" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Açılma: {date}" +"{tool} hold {key}" = "{tool} {key} basılı tutun" +"{value} isn't a colour" = "{value} bir renk değil" +"{when} · edited" = "{when} · düzenlendi" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Boşluk veya N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ oklar" +"⌘ click" = "⌘ tıklama" +"⌘ scroll" = "⌘ kaydırma" +"⌥ drag" = "⌥ sürükle" + +[commands] +"align.bottom" = "Alta hizala" +"align.hcenter" = "Yatay merkezleri hizala" +"align.left" = "Sola hizala" +"align.right" = "Sağa hizala" +"align.top" = "Üste hizala" +"align.vcenter" = "Dikey merkezleri hizala" +"annotation.add" = "Ek açıklama ekle" +"annotation.addCategory" = "Ek açıklama kategorisi ekle" +"annotation.categories" = "Ek açıklama kategorileri" +"annotation.edit" = "Ek açıklamayı düzenle" +"annotation.editCategory" = "Ek açıklama kategorisini düzenle" +"annotation.list" = "Ek açıklamaları listele" +"annotation.remove" = "Ek açıklamayı kaldır" +"annotation.removeCategory" = "Ek açıklama kategorisini sil" +"app.actions" = "Eylemler" +"app.preferences" = "Tercihler" +"arrange.back" = "En arkaya gönder" +"arrange.backward" = "Geriye gönder" +"arrange.forward" = "Öne getir" +"arrange.front" = "En öne getir" +"arrange.packH" = "Yatay sıkıştır" +"arrange.packV" = "Dikey sıkıştır" +"arrange.roundToPixels" = "Piksellere yuvarla" +"arrange.tidy" = "Toparla" +"bool.exclude" = "Seçimi hariç tut" +"bool.flatten" = "Boolean'ı düzleştir" +"bool.intersect" = "Seçimi kesiştir" +"bool.subtract" = "Seçimi çıkar" +"bool.union" = "Seçimi birleştir" +"branch.archive" = "Dalı arşivle" +"branch.create" = "Dal oluştur" +"branch.list" = "Dalları listele" +"branch.merge" = "Dalı birleştir" +"branch.open" = "Dalı aç" +"branch.review" = "Dalı gözden geçir" +"branch.updateFromMain" = "Ana dosyadan güncelle" +"codeConnect.export" = "Kod bağlantılarını dışa aktar" +"codeConnect.import" = "Kod bağlantılarını içe aktar" +"codeConnect.remove" = "Kod bağlantısını kes" +"codeConnect.set" = "Koda bağla" +"collab.cursor" = "İmleci şurada göster" +"collab.cursorChat" = "İmleç sohbeti" +"collab.diff" = "Eşitleme değişikliklerini göster" +"collab.files" = "Çevrimiçi dosyalarınız" +"collab.follow" = "Takip et" +"collab.forgetFile" = "Çevrimiçi dosyayı unut" +"collab.host" = "Canlı oturum başlat" +"collab.join" = "Canlı oturuma katıl" +"collab.leave" = "Canlı oturumdan ayrıl" +"collab.manageFile" = "Yayımlanan dosyayı yönet" +"collab.openFile" = "Çevrimiçi dosya aç" +"collab.peers" = "Bu dosyadaki kişiler" +"collab.publish" = "Sunucuya yayımla" +"collab.recoverFile" = "Çevrimiçi dosyayı kurtar" +"collab.registerLinks" = "Davet bağlantılarını FigCraft'ta aç" +"collab.report" = "Dosyayı sunucu işletmecisine bildir" +"collab.serverInfo" = "Sunucu bilgisi" +"collab.share" = "Katılma koduyla paylaş" +"collab.spotlight" = "Beni öne çıkar" +"collab.stop" = "Canlı oturumu durdur" +"command.batch" = "Birkaç komut çalıştır" +"comment.add" = "Yorum ekle" +"comment.delete" = "Yorumu sil" +"comment.edit" = "Yorumu düzenle" +"comment.link" = "Yorum bağlantısını kopyala" +"comment.list" = "Yorumları listele" +"comment.markRead" = "Yorumu okundu olarak işaretle" +"comment.move" = "Yorumu taşı" +"comment.next" = "Sonraki yorum" +"comment.open" = "Yorumu aç" +"comment.people" = "Yorumlardaki kişiler" +"comment.react" = "Yoruma tepki ver" +"comment.reply" = "Yorumu yanıtla" +"comment.resolve" = "Yorumu çöz" +"comment.zoom" = "Yorumlara yakınlaştır" +"component.addProperty" = "Bileşen özelliği ekle" +"component.addVariant" = "Varyant ekle" +"component.bindProperty" = "Katmanı özelliğe bağla" +"component.combineVariants" = "Varyant olarak birleştir" +"component.create" = "Bileşen oluştur" +"component.createMultiple" = "Birden çok bileşen oluştur" +"component.deleteProperty" = "Bileşen özelliğini sil" +"component.detach" = "Örneği ayır" +"component.editProperty" = "Bileşen özelliğini düzenle" +"component.exposeInstance" = "İç içe örneği aç" +"component.goToMain" = "Ana bileşene git" +"component.hideWhenPublishing" = "Yayımlarken gizle" +"component.list" = "Bileşenleri listele" +"component.makeSlot" = "Yuva yap" +"component.playground" = "Bileşen deneme alanı" +"component.properties" = "Bileşen özellikleri" +"component.pushChanges" = "Değişiklikleri ana bileşene gönder" +"component.removeSlot" = "Yuvayı kaldır" +"component.resetInstance" = "Tüm değişiklikleri sıfırla" +"component.resetOverride" = "Geçersiz kılmayı sıfırla" +"component.restoreMain" = "Ana bileşeni geri yükle" +"dev.compare" = "Değişiklikleri karşılaştır" +"dev.focus" = "Odak görünümü" +"dev.resolveChange" = "Değişikliği çöz" +"dev.unfocus" = "Odak görünümünden çık" +"devResource.add" = "Geliştirici kaynağı ekle" +"devResource.edit" = "Geliştirici kaynağını düzenle" +"devResource.list" = "Geliştirici kaynaklarını listele" +"devResource.remove" = "Geliştirici kaynağını kaldır" +"distribute.horizontal" = "Yatay aralığı dağıt" +"distribute.vertical" = "Dikey aralığı dağıt" +"doc.home" = "Ana sayfa" +"doc.list" = "Belgeleri listele" +"doc.nextTab" = "Sonraki sekme" +"doc.previousTab" = "Önceki sekme" +"doc.switch" = "Belge değiştir" +"edit.copy" = "Kopyala" +"edit.copyAsCss" = "CSS olarak kopyala" +"edit.copyAsPng" = "PNG olarak kopyala" +"edit.copyAsSvg" = "SVG olarak kopyala" +"edit.copyAsText" = "Metin olarak kopyala" +"edit.copyLink" = "Seçimin bağlantısını kopyala" +"edit.copyProperties" = "Özellikleri kopyala" +"edit.cut" = "Kes" +"edit.delete" = "Sil" +"edit.duplicate" = "Çoğalt" +"edit.findReplace" = "Bul ve değiştir" +"edit.paste" = "Yapıştır" +"edit.pasteOver" = "Seçimin üzerine yapıştır" +"edit.pasteProperties" = "Özellikleri yapıştır" +"edit.pasteReplace" = "Değiştirmek için yapıştır" +"edit.pickColor" = "Renk seç" +"edit.redo" = "Yinele" +"edit.repeat" = "Son komutu tekrarla" +"edit.setDefaultProperties" = "Varsayılan özellikleri ayarla" +"edit.undo" = "Geri al" +"effect.add" = "Efekt ekle" +"effect.move" = "Efekti taşı" +"effect.remove" = "Efekti kaldır" +"effect.set" = "Efekti ayarla" +"export.add" = "Dışa aktarma ayarı ekle" +"export.remove" = "Dışa aktarma ayarını kaldır" +"export.update" = "Dışa aktarma ayarını düzenle" +"file.close" = "Sekmeyi kapat" +"file.colorProfile" = "Renk profili" +"file.duplicate" = "Dosyayı çoğalt" +"file.export" = "Dışa aktar" +"file.exportFramesPdf" = "Çerçeveleri PDF olarak dışa aktar" +"file.exportLayers" = "Katmanları dışa aktar" +"file.exportSvg" = "Seçimi SVG olarak dışa aktar" +"file.import" = "İçe aktar" +"file.new" = "Yeni tasarım dosyası" +"file.newFromTemplate" = "Şablondan yeni" +"file.open" = "Aç" +"file.openRecent" = "Son kullanılanı aç" +"file.placeImage" = "Görüntü yerleştir" +"file.recover" = "Dosyayı kurtar" +"file.rename" = "Dosyayı yeniden adlandır" +"file.revert" = "Kaydedilene geri dön" +"file.save" = "Kaydet" +"file.saveAs" = "Farklı kaydet" +"file.saveVersion" = "Sürüm geçmişine kaydet" +"file.versionHistory" = "Sürüm geçmişini göster" +"fill.add" = "Dolgu ekle" +"fill.move" = "Dolguyu taşı" +"fill.pattern" = "Desen dolgusu" +"fill.remove" = "Dolguyu kaldır" +"fill.set" = "Dolguyu ayarla" +"frame.create" = "Çerçeve oluştur" +"frame.presets" = "Çerçeve ön ayarlarını listele" +"frame.swapOrientation" = "Yönü değiştir" +"grid.add" = "Yerleşim ızgarası ekle" +"grid.move" = "Yerleşim ızgarasını taşı" +"grid.remove" = "Yerleşim ızgarasını kaldır" +"guide.add" = "Kılavuz ekle" +"guide.clear" = "Tüm kılavuzları kaldır" +"guide.move" = "Kılavuzu taşı" +"guide.remove" = "Kılavuzu kaldır" +"guide.select" = "Kılavuzu seç" +"help.gettingStarted" = "Başlarken" +"help.about" = "FigCraft hakkında" +"help.page" = "Yardım sayfası" +"help.shortcuts" = "Klavye kısayolları" +"image.adjust" = "Görüntüyü ayarla" +"image.deletePreset" = "Görüntü ön ayarını sil" +"image.place" = "Görüntü yerleştir" +"image.presets" = "Görüntü ön ayarları" +"image.savePreset" = "Görüntü ön ayarını kaydet" +"inspect.assets" = "Varlıkları incele" +"inspect.code" = "Katmanın kodu" +"inspect.componentCode" = "Bileşen kodu" +"inspect.properties" = "Özellikleri incele" +"inspect.readyForDev" = "Geliştirmeye hazır" +"instance.create" = "Örnek oluştur" +"instance.exposed" = "Açılan iç içe örnekler" +"instance.place" = "Örnek yerleştir" +"instance.resetSlot" = "Yuvayı sıfırla" +"instance.setProperties" = "Örnek özelliklerini ayarla" +"instance.slotInsert" = "Yuvaya ekle" +"instance.swap" = "Örneği değiştir" +"instance.swapAt" = "Noktadaki örneği değiştir" +"layers.collapseAll" = "Katmanları daralt" +"layout.add" = "Otomatik yerleşim ekle" +"layout.remove" = "Otomatik yerleşimi kaldır" +"layout.setTrack" = "Izgara izi boyutunu ayarla" +"layout.suggest" = "Otomatik yerleşim öner" +"libraries.open" = "Kitaplıklar" +"libraries.publish" = "Kitaplığı yayımla…" +"library.acceptUpdates" = "Kitaplık güncellemelerini kabul et" +"library.add" = "Kitaplık ekle" +"library.aliasVariable" = "Kitaplık değişkenine takma ad ver" +"library.applyStyle" = "Kitaplık stilini uygula" +"library.bindVariable" = "Kitaplık değişkenini bağla" +"library.extendCollection" = "Kitaplık koleksiyonunu genişlet" +"library.import" = "Kitaplık bileşenini içe aktar" +"library.importVariable" = "Kitaplık değişkenini içe aktar" +"library.list" = "Kitaplıkları listele" +"library.place" = "Kitaplık bileşenini yerleştir" +"library.publish" = "Kitaplığı yayımla" +"library.publishToServer" = "Kitaplığı sunucuya yayımla" +"library.remove" = "Kitaplığı kaldır" +"library.setVariableMode" = "Kitaplık değişken modunu ayarla" +"library.swap" = "Kitaplığı değiştir" +"library.swapAt" = "Örneği kitaplık bileşeniyle değiştir" +"library.updates" = "Kitaplık güncellemeleri" +"library.usage" = "Kitaplık kullanımı" +"measure.distances" = "Mesafeleri ölç" +"measure.padding" = "Otomatik yerleşim iç boşluğunu ölç" +"node.create" = "Katman oluştur" +"node.get" = "Katmanı getir" +"node.reorder" = "Katmanı yeniden sırala" +"node.reparent" = "Katmanı içine taşı" +"node.set" = "Özellikleri ayarla" +"object.batchRename" = "Toplu yeniden adlandır" +"object.convertToFrame" = "Çerçeveye dönüştür" +"object.convertToGroup" = "Gruba dönüştür" +"object.flipH" = "Yatay çevir" +"object.flipV" = "Dikey çevir" +"object.frameSelection" = "Seçimi çerçevele" +"object.group" = "Seçimi grupla" +"object.hideOthers" = "Diğer katmanları gizle" +"object.mask" = "Maske olarak kullan" +"object.moveToPage" = "Sayfaya taşı" +"object.opacityKeys" = "Rakam tuşlarıyla opaklık" +"object.rasterize" = "Seçimi rasterleştir" +"object.releaseMask" = "Maskeyi bırak" +"object.removeFill" = "Dolguyu kaldır" +"object.removeFrame" = "Çerçeveyi kaldır" +"object.removeStroke" = "Konturu kaldır" +"object.rename" = "Yeniden adlandır" +"object.resizeToFit" = "Sığacak şekilde yeniden boyutlandır" +"object.rotate180" = "180° döndür" +"object.rotateLeft" = "90° sola döndür" +"object.rotateRight" = "90° sağa döndür" +"object.setThumbnail" = "Küçük resim olarak ayarla" +"object.swapFillStroke" = "Dolgu ve konturu değiştir" +"object.toggleLock" = "Seçimi kilitle/kilidini aç" +"object.toggleVisible" = "Seçimi göster/gizle" +"object.ungroup" = "Seçimin grubunu çöz" +"object.wrapInSection" = "Yeni bölüme sar" +"page.add" = "Sayfa ekle" +"page.delete" = "Sayfayı sil" +"page.duplicate" = "Sayfayı çoğalt" +"page.list" = "Sayfaları listele" +"page.move" = "Sayfayı taşı" +"page.rename" = "Sayfayı yeniden adlandır" +"page.select" = "Sayfaya git" +"page.setBackground" = "Sayfa arka planı" +"paint.documentColors" = "Belge renkleri" +"plugin.close" = "Eklenti panelini kapat" +"plugin.directory.install" = "Dizinden eklenti yükle" +"plugin.directory.list" = "Eklenti dizinine gözat" +"plugin.directory.publish" = "Eklentiyi dizine yayımla" +"plugin.directory.unpublish" = "Eklentiyi dizinden kaldır" +"plugin.event" = "Eklenti paneli denetimini kullan" +"plugin.import" = "Eklentiyi manifestten içe aktar…" +"plugin.list" = "Eklentileri listele" +"plugin.remove" = "Eklentiyi kaldır" +"plugin.run" = "Eklentiyi çalıştır" +"plugin.ui" = "Eklenti paneli" +"plugins.manage" = "Eklentileri yönet…" +"plugins.runLast" = "Son eklentiyi çalıştır" +"pref.highlightHover" = "Üzerine gelince katmanları vurgula" +"pref.keepTool" = "Kullanımdan sonra aracı seçili tut" +"pref.language" = "Arayüz dili" +"pref.nudge" = "Kaydırma miktarı" +"pref.numberKeysOpacity" = "Opaklık için rakam tuşlarını kullan" +"pref.publishServer" = "Çevrimiçi paylaşım sunucusu" +"pref.reduceMotion" = "Hareketi azalt" +"pref.renameDuplicates" = "Çoğaltılan katmanları yeniden adlandır" +"pref.showDimensions" = "Nesnelerde boyutları göster" +"pref.userName" = "Yorumlardaki adınız" +"proto.action.add" = "Prototip eylemi ekle" +"proto.action.move" = "Prototip eylemini yeniden sırala" +"proto.action.remove" = "Prototip eylemini kaldır" +"proto.action.set" = "Prototip eylemini düzenle" +"proto.closePreview" = "Prototip önizlemesini kapat" +"proto.conditional.addBlock" = "Değilse ve eğer / değilse ekle" +"proto.conditional.removeBlock" = "Eğer / değilse ve eğer / değilse kaldır" +"proto.conditional.setCondition" = "Koşulu düzenle" +"proto.evaluate" = "Prototip ifadesini değerlendir" +"proto.flow.add" = "Akış başlangıç noktası ekle" +"proto.flow.remove" = "Akışı kaldır" +"proto.flow.rename" = "Akışı yeniden adlandır" +"proto.interaction.add" = "Etkileşim ekle" +"proto.interaction.clear" = "Tüm etkileşimleri kaldır" +"proto.interaction.remove" = "Etkileşimi kaldır" +"proto.interaction.set" = "Etkileşimi düzenle" +"proto.present" = "Sun" +"proto.presentOptions" = "Sunum seçenekleri" +"proto.preview" = "Prototip önizlemesini aç" +"proto.settings" = "Prototip ayarları" +"section.toggleContents" = "Bölüm içeriğini gizle/göster" +"select.add" = "Seçime ekle" +"select.all" = "Tümünü seç" +"select.area" = "Alan seç" +"select.byName" = "Katmanları ada göre seç" +"select.children" = "Alt öğeleri seç" +"select.inverse" = "Seçimi tersine çevir" +"select.layersAt" = "Bir noktadaki katmanlar" +"select.matching" = "Eşleşen katmanları seç" +"select.next" = "Sonraki kardeşi seç" +"select.none" = "Seçimi kaldır" +"select.parent" = "Üst öğeyi seç" +"select.previous" = "Önceki kardeşi seç" +"select.remove" = "Seçimden çıkar" +"select.sameEffect" = "Aynı efekti seç" +"select.sameFill" = "Aynı dolguyu seç" +"select.sameFont" = "Aynı yazı tipini seç" +"select.sameInstance" = "Aynı örneği seç" +"select.sameProperties" = "Aynı özellikleri seç" +"select.sameStroke" = "Aynı konturu seç" +"select.sameText" = "Aynı metin özelliklerini seç" +"select.set" = "Seç" +"shape.arrow" = "Ok" +"shape.ellipse" = "Elips" +"shape.line" = "Çizgi" +"shape.polygon" = "Çokgen" +"shape.rectangle" = "Dikdörtgen" +"shape.star" = "Yıldız" +"shapeBuilder.merge" = "Bölgeleri birleştir" +"shapeBuilder.mode" = "Şekil oluşturucu modu" +"shapeBuilder.regions" = "Şekil oluşturucu bölgeleri" +"shapeBuilder.remove" = "Bölgeleri kaldır" +"stroke.add" = "Kontur ekle" +"stroke.brush" = "Fırça" +"stroke.move" = "Konturu taşı" +"stroke.remove" = "Konturu kaldır" +"stroke.set" = "Konturu ayarla" +"stroke.widthProfile" = "Genişlik profili" +"style.apply" = "Stili uygula" +"style.bind" = "Stilde değişken bağla" +"style.create" = "Stil oluştur" +"style.delete" = "Stili sil" +"style.detach" = "Stili ayır" +"style.duplicate" = "Stili çoğalt" +"style.list" = "Stilleri listele" +"style.move" = "Stilleri taşı" +"style.renameFolder" = "Stil klasörünü yeniden adlandır" +"style.update" = "Stili düzenle" +"text.alignCenter" = "Ortaya hizala" +"text.alignJustify" = "İki yana yasla" +"text.alignLeft" = "Sola hizala" +"text.alignRight" = "Sağa hizala" +"text.bold" = "Kalın" +"text.bulletList" = "Madde işaretli liste" +"text.case" = "Harf durumu" +"text.caseLower" = "Küçük harf" +"text.caseOriginal" = "Özgün harf durumu" +"text.caseSmallCaps" = "Küçük büyük harf" +"text.caseTitle" = "Başlık biçimi" +"text.caseUpper" = "Büyük harf" +"text.create" = "Metin" +"text.decoration" = "Süsleme" +"text.edit.begin" = "Metni düzenle" +"text.edit.caret" = "İmleci konumlandır" +"text.edit.end" = "Metin düzenlemeyi bitir" +"text.edit.insert" = "Metin yaz" +"text.edit.key" = "Metin tuşu" +"text.edit.preedit" = "Metin oluştur (IME)" +"text.edit.selectLine" = "Satırı seç" +"text.edit.selectWord" = "Sözcüğü seç" +"text.edit.selection" = "Metin seçimi" +"text.endPreview" = "Metin önizlemesini bitir" +"text.fonts" = "Yazı tiplerini listele" +"text.italic" = "İtalik" +"text.lineHeightDown" = "Satır yüksekliğini azalt" +"text.lineHeightUp" = "Satır yüksekliğini artır" +"text.link" = "Bağlantı oluştur" +"text.listIndent" = "Listeyi girintile" +"text.listOutdent" = "Liste girintisini azalt" +"text.listStyle" = "Liste stili" +"text.maxLines" = "En fazla satır" +"text.numberList" = "Numaralı liste" +"text.onPath" = "Yoldaki metin" +"text.paragraphIndent" = "Paragraf girintisi" +"text.paragraphSpacing" = "Paragraf aralığı" +"text.pathOptions" = "Metin yolu seçenekleri" +"text.preview" = "Metin değişikliğini önizle" +"text.removeFromPath" = "Metni yoldan kaldır" +"text.set" = "Metni ayarla" +"text.setFeatures" = "OpenType özellikleri" +"text.setVariations" = "Değişken yazı tipi eksenleri" +"text.sizeDown" = "Yazı tipi boyutunu küçült" +"text.sizeUp" = "Yazı tipi boyutunu büyüt" +"text.strikethrough" = "Üstü çizili" +"text.style" = "Metin aralığına stil uygula" +"text.trackingDown" = "Harf aralığını azalt" +"text.trackingUp" = "Harf aralığını artır" +"text.underline" = "Altı çizili" +"text.weightDown" = "Yazı tipi kalınlığını azalt" +"text.weightUp" = "Yazı tipi kalınlığını artır" +"tool.arrow" = "Ok" +"tool.comment" = "Yorum" +"tool.ellipse" = "Elips" +"tool.frame" = "Çerçeve" +"tool.hand" = "El" +"tool.line" = "Çizgi" +"tool.move" = "Taşı" +"tool.pen" = "Kalem" +"tool.pencil" = "Kurşun kalem" +"tool.pencilBrush" = "Kurşun kalem fırçası" +"tool.place" = "Araçla yerleştir" +"tool.polygon" = "Çokgen" +"tool.rectangle" = "Dikdörtgen" +"tool.scale" = "Ölçekle" +"tool.scaleAnchor" = "Ölçekleme bağlantı noktası" +"tool.section" = "Bölüm" +"tool.set" = "Araç seç" +"tool.shapeBuilder" = "Şekil oluşturucu" +"tool.slice" = "Dilim" +"tool.star" = "Yıldız" +"tool.text" = "Metin" +"transform.move" = "Taşı" +"transform.moveTo" = "Şuraya taşı" +"transform.nudge" = "Kaydır" +"transform.nudgeSize" = "Kaydırma ile yeniden boyutlandır" +"transform.resize" = "Yeniden boyutlandır" +"transform.rotate" = "Döndür" +"transform.rotationOrigin" = "Döndürme merkezini taşı" +"transform.scale" = "Ölçekle" +"transform.setRotationOrigin" = "Döndürme merkezini ayarla" +"variable.addMode" = "Mod ekle" +"variable.bind" = "Değişken bağla" +"variable.create" = "Değişken oluştur" +"variable.createCollection" = "Değişken koleksiyonu oluştur" +"variable.delete" = "Değişkeni sil" +"variable.deleteCollection" = "Koleksiyonu sil" +"variable.duplicate" = "Değişkeni çoğalt" +"variable.export" = "Değişkenleri dışa aktar" +"variable.import" = "Değişkenleri içe aktar" +"variable.list" = "Değişkenleri listele" +"variable.moveMode" = "Modu taşı" +"variable.removeMode" = "Modu sil" +"variable.renameCollection" = "Koleksiyonu yeniden adlandır" +"variable.renameMode" = "Modu yeniden adlandır" +"variable.resetOverride" = "Değişken geçersiz kılmasını sıfırla" +"variable.set" = "Değişken değerini ayarla" +"variable.setMode" = "Değişken modunu ayarla" +"variable.update" = "Değişkeni düzenle" +"vector.create" = "Vektör" +"vector.deleteHeal" = "Seçimi sil ve onar" +"vector.edit.addVertex" = "Nokta ekle" +"vector.edit.begin" = "Vektörü düzenle" +"vector.edit.bend" = "Parçayı bük" +"vector.edit.bounds" = "Seçili noktaların sınırları" +"vector.edit.connect" = "Noktaları bağla" +"vector.edit.cornerRadius" = "Nokta köşe yarıçapı" +"vector.edit.cut" = "Yolu kes" +"vector.edit.delete" = "Noktaları sil" +"vector.edit.end" = "Düzenlemeyi bitir" +"vector.edit.erase" = "Sil" +"vector.edit.lasso" = "Kement seçimi" +"vector.edit.mirroring" = "Tutamaç yansıtma" +"vector.edit.moveHandles" = "Tutamaçları taşı" +"vector.edit.moveVertices" = "Noktaları taşı" +"vector.edit.nudge" = "Noktaları kaydır" +"vector.edit.paint" = "Boya kovası" +"vector.edit.pen" = "Kalem noktası" +"vector.edit.penEnd" = "Kalem yolunu bitir" +"vector.edit.position" = "Nokta konumu" +"vector.edit.resize" = "Noktaları yeniden boyutlandır" +"vector.edit.rotate" = "Noktaları döndür" +"vector.edit.select" = "Noktaları seç" +"vector.edit.selectAll" = "Tüm noktaları seç" +"vector.edit.setHandle" = "Tutamacı ayarla" +"vector.edit.smooth" = "Noktaları yumuşat veya keskinleştir" +"vector.edit.state" = "Vektör düzenleme durumu" +"vector.edit.strokeCap" = "Nokta kontur ucu" +"vector.edit.strokeJoin" = "Nokta kontur birleşimi" +"vector.edit.tool" = "Vektör düzenleme aracı" +"vector.edit.transform" = "Noktaları dönüştür" +"vector.edit.widthPoint" = "Genişlik noktası" +"vector.flatten" = "Düzleştir" +"vector.join" = "Seçimi birleştir" +"vector.offset" = "Yolu ötele" +"vector.outlineStroke" = "Konturu ana hatlara dönüştür" +"vector.reverse" = "Yol yönünü ters çevir" +"vector.simplify" = "Vektörü sadeleştir" +"vector.smoothJoin" = "Seçimi yumuşak birleştir" +"version.autosave" = "Otomatik kayıt sürümü" +"version.delete" = "Sürümü sil" +"version.duplicate" = "Sürümü çoğalt" +"version.exitPreview" = "Geçerli sürüme dön" +"version.list" = "Sürümleri listele" +"version.preview" = "Sürümü görüntüle" +"version.rename" = "Sürümü yeniden adlandır" +"version.restore" = "Sürümü geri yükle" +"view.assets" = "Varlıklar paneli" +"view.comments" = "Yorumlar" +"view.contextMenu" = "Bağlam menüsünü göster" +"view.design" = "Tasarım paneli" +"view.designPrototype" = "Tasarım ve Prototip arasında geçiş yap" +"view.devMode" = "Geliştirici Modu" +"view.frameOutlines" = "Çerçeve ana hatları" +"view.get" = "Görünümü getir" +"view.layers" = "Katmanlar paneli" +"view.layoutGrids" = "Yerleşim ızgaraları" +"view.leftSidebar" = "Sol kenar çubuğunu göster/gizle" +"view.maskOutlines" = "Maske ana hatları" +"view.multiplayerCursors" = "Çok oyunculu imleçler" +"view.nextFrame" = "Sonraki çerçeveye yakınlaştır" +"view.nextPage" = "Sonraki sayfa" +"view.nextRegion" = "Sonraki bölgeye odaklan" +"view.outlines" = "Ana hatlar" +"view.outlinesHiddenLayers" = "Ana hatlara gizli katmanları dahil et" +"view.outlinesObjectBounds" = "Ana hatlara nesne sınırlarını dahil et" +"view.outlinesSelection" = "Seçimin ana hatları" +"view.pan" = "Görünümü kaydır" +"view.panelWidths" = "Panel genişliklerini sıfırla" +"view.pixelGrid" = "Piksel ızgarası" +"view.pixelPreview" = "Piksel önizlemesi" +"view.prevFrame" = "Önceki çerçeveye yakınlaştır" +"view.prevPage" = "Önceki sayfa" +"view.previousRegion" = "Önceki bölgeye odaklan" +"view.proofSrgb" = "sRGB ekran provası" +"view.fullscreen" = "Tam ekran" +"view.prototype" = "Prototip paneli" +"view.rulers" = "Cetveller" +"view.set" = "Görünümü ayarla" +"view.slices" = "Dilimler" +"view.snapGeometry" = "Geometriye yapıştır" +"view.snapObjects" = "Nesnelere yapıştır" +"view.snapPixel" = "Piksel ızgarasına yapıştır" +"view.themeDark" = "Koyu tema" +"view.themeLight" = "Açık tema" +"view.themeSystem" = "Sistem temasını izle" +"view.toggleUi" = "Arayüzü göster/gizle" +"view.zoom100" = "%100'e yakınlaştır" +"view.zoom200" = "%200'e yakınlaştır" +"view.zoom50" = "%50'ye yakınlaştır" +"view.zoomAt" = "Yakınlaştırma aracı" +"view.zoomFit" = "Sığdırmak için yakınlaştır" +"view.zoomIn" = "Yakınlaştır" +"view.zoomOut" = "Uzaklaştır" +"view.zoomSelection" = "Seçime yakınlaştır" +"widget.click" = "Widget'a tıkla" +"widget.insert" = "Widget ekle" +"widget.list" = "Widget'ları listele" +"window.focus" = "Pencereyi öne getir" +"window.new" = "Yeni pencere" +"template.list" = "Şablonları listele" + +[menu] +"bool.exclude" = "Hariç tut" +"bool.flatten" = "Düzleştir" +"bool.intersect" = "Kesiştir" +"bool.subtract" = "Çıkar" +"bool.union" = "Birleştir" +"file.duplicate" = "Çoğalt" +"file.import" = "Dosyadan içe aktar…" +"file.rename" = "Yeniden adlandır" +"libraries.open" = "Kitaplıkları aç" +"select.sameEffect" = "Efekt" +"select.sameFill" = "Dolgu" +"select.sameFont" = "Yazı tipi" +"select.sameInstance" = "Örnek" +"select.sameProperties" = "Özellikler" +"select.sameStroke" = "Kontur" +"select.sameText" = "Metin özellikleri" +"text.alignCenter" = "Metni ortala" +"text.alignJustify" = "Metni iki yana yasla" +"text.alignLeft" = "Metni sola hizala" +"text.alignRight" = "Metni sağa hizala" +"view.assets" = "Varlıklar" +"view.design" = "Tasarım" +"view.layers" = "Katmanlar" +"view.outlines" = "Ana hatları göster" +"view.outlinesHiddenLayers" = "Gizli katmanları dahil et" +"view.outlinesObjectBounds" = "Nesne sınırlarını dahil et" +"view.outlinesSelection" = "Seçimin ana hatlarını göster" +"view.prototype" = "Prototip" +"view.themeDark" = "Koyu" +"view.themeLight" = "Açık" +"view.themeSystem" = "Sistemi izle" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d2431df09..af4cf6534 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" @@ -303,6 +304,7 @@ status = "draft — needs native review" "Corner radius" = "圆角半径" "Corner smoothing" = "圆角平滑" "Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。" +"Couldn't save automatically: {error}" = "无法自动保存:{error}" "Count" = "数量" "Create" = "创建" "Create a variable collection first" = "请先创建变量集合" @@ -458,6 +460,7 @@ status = "draft — needs native review" "Fade" = "褪色" "False" = "假" "Fetching from the server…" = "正在从服务器获取…" +"FigCraft couldn't open its window: {error}" = "FigCraft 无法打开窗口:{error}" "File" = "文件" "File name" = "文件名" "File published" = "文件已发布" @@ -569,6 +572,7 @@ status = "draft — needs native review" "Hue" = "色相" "Hug" = "适应" "Hug contents" = "适应内容" +"If it keeps happening, please report it at {link} with this message." = "如果问题持续出现,请附上此消息到 {link} 报告。" "If" = "如果" "If {condition}" = "如果 {condition}" "Ignore auto layout (absolute position)" = "忽略自动布局(绝对定位)" @@ -999,6 +1003,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "移除“否则”及其操作" "Remove this condition and its actions" = "移除此条件及其操作" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "要从服务器移除此文件吗?之后将无法再从服务器分享或打开它。你的副本仍在此处打开。" +"Remove {name} from session" = "将 {name} 移出会话" "Remove {name} from {server}?" = "要从 {server} 移除 {name} 吗?" "Removed" = "已删除" "Removed from the server" = "已从服务器移除" @@ -1072,6 +1077,7 @@ status = "draft — needs native review" "Save" = "保存" "Save as" = "另存为" "Save as preset…" = "存储为预设…" +"Save files automatically" = "自动保存文件" "Save version {key}" = "保存版本 {key}" "Saved" = "已保存" "Saved {name}" = "已保存 {name}" @@ -1324,6 +1330,8 @@ status = "draft — needs native review" "Update plugin" = "更新插件" "Updated {components} and {styles}" = "已更新 {components} 和 {styles}" "Updated {name}" = "已更新 {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "更新显卡驱动通常会有帮助。若只想使用 OpenGL,请在命令提示符中这样启动 FigCraft:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "更新显卡驱动通常会有帮助。可用 WGPU_BACKEND(vulkan、gl、metal)指定另一个图形 API 来尝试。" "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "将此文件上传到 FigCraft 服务器,并使此标签页与之保持同步。你会获得一个编辑链接和一个查看链接。" "Uppercase" = "大写" "Use a fixed value" = "使用固定值" @@ -1365,6 +1373,7 @@ status = "draft — needs native review" "Visibility" = "可见性" "Visible" = "可见" "Vivid" = "鲜艳" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "已设置 WGPU_BACKEND,它指定了要使用的图形 API;取消设置后由 FigCraft 自行选择。" "Warm" = "暖调" "Watch" = "手表" "Wavy" = "波浪线" @@ -1570,6 +1579,7 @@ status = "draft — needs native review" "collab.publish" = "发布到服务器" "collab.recoverFile" = "恢复在线文件" "collab.registerLinks" = "在 FigCraft 中打开邀请链接" +"collab.removePerson" = "移出会话" "collab.report" = "向服务器运营者举报文件" "collab.serverInfo" = "服务器信息" "collab.share" = "用加入代码分享" @@ -1786,6 +1796,7 @@ status = "draft — needs native review" "plugin.ui" = "插件面板" "plugins.manage" = "管理插件…" "plugins.runLast" = "运行上一个插件" +"pref.autoSave" = "自动保存文件" "pref.highlightHover" = "悬停时高亮图层" "pref.keepTool" = "使用后保持工具选中" "pref.language" = "界面语言" diff --git a/crates/ui-egui/src/a11y_tests.rs b/crates/ui-egui/src/a11y_tests.rs index 3fe858760..d041a5b36 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 374855682..a008ae808 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 { @@ -1088,11 +1100,12 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { trn("Swapped {n} assets; {missed} had no match and still use the old library", moved as usize, &[("missed", &missed)]) }); } - if add - && let Some(p) = app.services.pick_open(tr("Add library"), &["figcraft"]) - && let Some(r) = app.run(ADD, json!({"path": p.to_string_lossy()})) - { - added(app, &r); + if add { + app.pick_open(tr("Add library"), &["figcraft"], |app, p| { + if let Some(r) = app.run(ADD, json!({"path": p.to_string_lossy()})) { + added(app, &r); + } + }); } } diff --git a/crates/ui-egui/src/async_dialog_tests.rs b/crates/ui-egui/src/async_dialog_tests.rs new file mode 100644 index 000000000..26808fe5d --- /dev/null +++ b/crates/ui-egui/src/async_dialog_tests.rs @@ -0,0 +1,92 @@ +use std::path::PathBuf; + +use figcraft_engine::Session; + +use crate::{DialogFuture, FigApp, Services}; + +struct OpenFile(PathBuf); + +impl Services for OpenFile { + fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(Some(self.0.clone()))) + } +} + +#[test] +fn open_dialog_finishes_after_the_async_picker_returns() { + let dir = std::env::temp_dir().join(format!("figcraft-async-open-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("opened.figcraft"); + let doc = figcraft_doc::Document::new("Opened"); + std::fs::write(&path, figcraft_format::save_bytes(&doc, None).unwrap()).unwrap(); + + let mut app = FigApp::new(Session::empty(), Box::new(OpenFile(path.clone()))); + app.ui.home = true; + app.open_dialog(); + assert!(app.session.docs.is_empty(), "opening waits for the picker result"); + + app.poll_dialog(&egui::Context::default()); + assert_eq!(app.session.docs.len(), 1); + assert_eq!(app.session.docs[0].path.as_deref(), Some(path.to_string_lossy().as_ref())); + assert!(!app.ui.home); + let _ = std::fs::remove_dir_all(dir); +} + +/// Every picker answers `self.0`. +struct Picks(PathBuf); + +impl Services for Picks { + fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(Some(self.0.clone()))) + } + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(Some(self.0.clone()))) + } +} + +/// Two files, A (the first) in front, and where the picker saves. +fn two_files(tag: &str) -> (FigApp, PathBuf, PathBuf) { + let dir = std::env::temp_dir().join(format!("figcraft-async-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("a.figcraft"); + let mut app = FigApp::new(Session::empty(), Box::new(Picks(path.clone()))); + for _ in 0..2 { + app.session.execute("file.new", &serde_json::json!({})).unwrap(); + } + app.session.active = 0; + (app, dir, path) +} + +#[test] +fn save_as_saves_the_file_it_was_opened_for_after_another_tab_comes_to_the_front() { + let (mut app, dir, path) = two_files("pin"); + app.save_as_dialog(); + // The user switches tabs while the dialog is open. + app.session.active = 1; + // Another window draws first: the result waits for the window holding the file. + app.windows.current = 7; + app.poll_dialog(&egui::Context::default()); + assert!(app.session.docs.iter().all(|d| d.path.is_none())); + app.windows.current = 0; + app.poll_dialog(&egui::Context::default()); + let p = path.to_string_lossy(); + assert_eq!(app.session.docs[0].path.as_deref(), Some(p.as_ref()), "A is saved where the user picked"); + assert_eq!(app.session.docs[1].path, None, "B is untouched"); + assert_eq!(app.session.active, 1, "the tab the user switched to stays in front"); + assert!(path.exists()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_dialog_whose_file_closed_meanwhile_does_nothing() { + let (mut app, dir, path) = two_files("closed"); + app.save_as_dialog(); + app.session.execute("file.close", &serde_json::json!({"index": 0, "force": true})).unwrap(); + app.poll_dialog(&egui::Context::default()); + assert!(app.session.docs.iter().all(|d| d.path.is_none()), "B isn't saved in A's place"); + assert!(!path.exists()); + assert!(app.pending_dialog.is_none()); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb23c8de0..3f848d5d8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -11,16 +11,27 @@ use serde_json::json; use crate::FigApp; use crate::i18n::{tr, trf, trn}; -use crate::render_worker::{Job, Worker}; +use crate::render_worker::{Done, Job, Worker}; use crate::theme::{ACCENT, COMPONENT, Tokens, doc_c32, regular, semibold}; pub struct CanvasState { pub rect: Rect, texture: Option, tex_view: Option, + /// The file ([`figcraft_engine::DocState::uid`]) and page the texture shows: it is drawn only + /// on them. + tex_of: Option<(u64, NodeId)>, + /// The texture is a coarse preview (see [`crate::render_worker`]). + pub tex_coarse: bool, submitted: Option, + /// The view of the last render asked for. + want_view: Option, /// What the last canvas render had to do (shown by `ui.inspect`). pub render_work: Option, + /// From asking for a render to its preview, and its refined image, handed to egui as the + /// canvas texture (ms; the texture is uploaded when that frame is painted). + pub preview_latency_ms: Option, + pub refine_latency_ms: Option, dragging: bool, space_pan: bool, middle_pan: Option<(Pos2, f64, f64)>, @@ -45,8 +56,13 @@ impl Default for CanvasState { rect: Rect::NOTHING, texture: None, tex_view: None, + tex_of: None, + tex_coarse: false, submitted: None, + want_view: None, render_work: None, + preview_latency_ms: None, + refine_latency_ms: None, dragging: false, space_pan: false, middle_pan: None, @@ -172,7 +188,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { request_render(app, worker, ppp); // Every finished render is shown, even when a newer one is already on its way: renders finish // in order, and an edit every frame (a drag) would otherwise never show until it stopped. - if let Some(done) = worker.poll() { + let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok(); + if let Some(done) = worker.poll() + && shows(&app.canvas, &done, shown) + { + if !done.coarse { + app.canvas.render_work = Some(done.work); + app.ui.render_ms = done.ms; + } // A pixel preview render is magnified with hard pixel edges. let filter = if app.session.ds().ok().and_then(|d| d.view.pixel_preview_scale()) == Some(done.view.scale) { egui::TextureOptions::NEAREST @@ -183,9 +206,15 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { Some(t) => t.set(done.image, filter), None => app.canvas.texture = Some(ctx.load_texture("figcraft-canvas", done.image, filter)), } + let latency = Some(done.at.elapsed().as_secs_f64() * 1000.0); + if done.coarse { + app.canvas.preview_latency_ms = latency; + } else { + app.canvas.refine_latency_ms = latency; + } app.canvas.tex_view = Some(done.view); - app.canvas.render_work = Some(done.work); - app.ui.render_ms = done.ms; + app.canvas.tex_of = Some((done.uid, done.page)); + app.canvas.tex_coarse = done.coarse; } let painter = ui.painter_at(rect); let t = Tokens::get(&ctx); @@ -200,7 +229,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { }) .unwrap_or(t.canvas); painter.rect_filled(rect, CornerRadius::ZERO, bg); - if let (Some(tex), Some(v)) = (&app.canvas.texture, &app.canvas.tex_view) { + if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) { // Re-project the last render onto the current view. let tl = app.to_screen(v.origin.0, v.origin.1); let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0); @@ -268,6 +297,30 @@ fn describe_box([x0, y0, x1, y1]: [f64; 4]) -> String { ) } +/// Whether a finished render goes on screen: an image of the file and page shown (`shown`), and +/// not a coarse preview over a refined image that already shows the whole view asked for at its +/// zoom (the same zoom elsewhere on the page is no such image). +fn shows(c: &CanvasState, done: &Done, shown: Option<(u64, NodeId)>) -> bool { + if shown != Some((done.uid, done.page)) { + return false; + } + let refined_here = !c.tex_coarse + && c.tex_of == shown + && c.tex_view + .as_ref() + .zip(c.want_view.as_ref()) + .is_some_and(|(have, want)| have.scale.to_bits() == want.scale.to_bits() && covers(have, want)); + !(done.coarse && refined_here) +} + +/// Whether `outer` shows all of the page that `inner` does. +fn covers(outer: &figcraft_render::View, inner: &figcraft_render::View) -> bool { + let (o, i) = (outer.page_rect(), inner.page_rect()); + // Rounding in the snapped origin, well under a device pixel. + let eps = 1e-6 / inner.scale.max(1e-9); + o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps +} + fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { let Ok(ds) = app.session.ds() else { return }; let rect = app.canvas.rect; @@ -313,7 +366,11 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { skip: vec![], solo: key.solo, proof, + uid: ds.uid, + zoom_preview: ds.view.pixel_preview_scale().is_none(), + at: web_time::Instant::now(), }; + app.canvas.want_view = Some(job.view.clone()); worker.submit(job); app.canvas.submitted = Some(key); } @@ -371,6 +428,13 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } } let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { + // A drag released off the canvas, or off the window, ends where the pointer was last seen. + if app.canvas.dragging && !ctx.input(|i| i.pointer.primary_down()) { + app.canvas.dragging = false; + if let Some(at) = hover_pos.or_else(|| app.canvas.last_pointer.map(|p| app.to_screen(p.x, p.y))) { + send(app, PointerKind::Up, at, &mods); + } + } // Leaving the canvas: nothing is hovered there any more. if !app.canvas.dragging && std::mem::take(&mut app.canvas.hovering) { app.session.vector_pointer_left(); @@ -482,19 +546,24 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { app.canvas.pressure = force; } if let Some(k) = kind { - let ev = pointer_event(app, k, pos, &mods); - if k == PointerKind::Up { - app.canvas.pressure = None; - } - app.canvas.last_pointer = Some(Point::new(ev.x, ev.y)); - if let Err(e) = app.session.pointer(&ev) { - app.toast(e.to_string()); - } - app.handle_ui_requests(); + send(app, k, pos, &mods); } true } +/// Pointer event `kind` at screen point `pos`, to the tools. +fn send(app: &mut FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers) { + let ev = pointer_event(app, kind, pos, m); + if kind == PointerKind::Up { + app.canvas.pressure = None; + } + app.canvas.last_pointer = Some(Point::new(ev.x, ev.y)); + if let Err(e) = app.session.pointer(&ev) { + app.toast(e.to_string()); + } + app.handle_ui_requests(); +} + /// Right-click at screen `pos`: select what's under the pointer (`deep`: the nested layer), then /// open the context menu there. pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) { @@ -973,12 +1042,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 +1198,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 +1246,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); @@ -1342,3 +1433,7 @@ pub fn fmt(v: f64) -> String { let r = (v * 100.0).round() / 100.0; if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } } + +#[cfg(test)] +#[path = "zoom_preview_tests.rs"] +mod zoom_preview_tests; diff --git a/crates/ui-egui/src/canvas_drag_tests.rs b/crates/ui-egui/src/canvas_drag_tests.rs index 9bc56fb2a..9f3b38397 100644 --- a/crates/ui-egui/src/canvas_drag_tests.rs +++ b/crates/ui-egui/src/canvas_drag_tests.rs @@ -59,3 +59,65 @@ fn a_drag_shows_on_the_canvas_while_it_moves() { assert!(u.app.worker.as_ref().is_some_and(|w| !w.in_flight()), "nothing left to render"); u.button(p + vec2(30.0, 10.0), false); } + +#[test] +fn releasing_a_canvas_drag_outside_finishes_the_interaction() { + let mut s = Session::new(); + let r = + figcraft_engine::params::id_of(&s.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap()["id"]).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + let start = u.app.to_screen(80.0, 70.0); + let outside = egui::pos2(u.app.canvas.rect.max.x + 20.0, start.y); + u.button(start, true); + u.frame(vec![Event::PointerMoved(outside)]); + u.button(outside, false); + + assert!(u.app.session.ds().unwrap().interaction.is_none(), "the document interaction is committed"); +} + +fn key(u: &mut Ui, key: egui::Key) { + let modifiers = egui::Modifiers::NONE; + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); +} + +#[test] +fn a_drag_released_off_the_window_ends_where_the_pointer_was_last_seen_and_later_edits_stay() { + // #278: a drag left open made a later text edit roll back the changes made since. + let mut s = Session::new(); + let id = |v: serde_json::Value| figcraft_engine::params::id_of(&v["id"]).unwrap(); + let kept = id(s.execute("shape.rectangle", &json!({"x": 40, "y": 400, "width": 80, "height": 40})).unwrap()); + let gone = id(s.execute("shape.rectangle", &json!({"x": 160, "y": 400, "width": 80, "height": 40})).unwrap()); + let text = id(s.execute("text.create", &json!({"text": "A", "x": 40, "y": 500})).unwrap()); + s.execute("tool.rectangle", &json!({})).unwrap(); + let mut u = Ui::new(s); + // A rectangle drawn from the middle of the canvas, released off the window, the pointer gone. + let start = u.app.canvas.rect.center(); + u.button(start, true); + for i in 1..=6 { + u.frame(vec![Event::PointerMoved(start + vec2(10.0, 6.0) * i as f32)]); + } + let release = + Event::PointerButton { pos: egui::pos2(-50.0, -50.0), button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + u.frame(vec![release, Event::PointerGone]); + let ds = u.app.session.ds().unwrap(); + assert!(ds.interaction.is_none(), "the drawing is committed"); + let (drawn, zoom) = (ds.selection[0], ds.view.zoom); + let b = ds.doc.world_bounds(drawn).unwrap(); + // Not the 100 x 100 shape of a click, as an end in the middle of the canvas would make. + assert!((b.width() * zoom - 60.0).abs() < 1.5 && (b.height() * zoom - 36.0).abs() < 1.5, "{b:?} at zoom {zoom}"); + // #278's steps: a move in the panel, a delete, then a text edit. + u.app.run("node.set", json!({"ids": [kept], "props": {"x": 650}})); + u.app.run("edit.delete", json!({"ids": [gone]})); + u.app.run("select.set", json!({"ids": [text]})); + key(&mut u, egui::Key::Enter); + u.frame(vec![Event::Text("B".into())]); + key(&mut u, egui::Key::Escape); + let doc = u.app.session.doc().unwrap(); + assert_eq!(doc.world_bounds(kept).unwrap().x0, 650.0, "the move stays"); + assert!(!doc.contains(gone), "the delete stays"); + assert_eq!(doc.node(text).unwrap().kind.text().unwrap().characters, "B"); +} diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 37b03e554..3b593daef 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}; @@ -62,6 +63,9 @@ fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: skip: vec![], solo: None, proof: false, + uid: 0, + zoom_preview: false, + at: web_time::Instant::now(), } } @@ -91,3 +95,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/cjk.rs b/crates/ui-egui/src/cjk.rs index 354d0e605..c7fb1f986 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -128,7 +128,7 @@ mod tests { assert_eq!(language("zh-Hant"), HAN); assert_eq!(language("zh-Hans"), HAN); assert_eq!(language("fr"), 0); - assert!(["en", "es", "es-419", "fr", "de", "pt-BR"].iter().all(|c| can_show(c))); + assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id"].iter().all(|c| can_show(c))); } #[test] diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 9a1123679..ac89ceb87 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")), ]; @@ -460,23 +460,21 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ui.add_space(12.0); if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) { let base = paint.preview_color(); + let mut image_pending = false; paint.kind = match i { 0 => PaintKind::Solid { color: base.with_alpha(1.0) }, 5 => { - // Pick an image file. - match app.services.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) { - Some(p) => match std::fs::read(&p) { - Ok(bytes) => { - let hash = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)); - match hash { - Some(h) => Paint::image(h, ImageScaleMode::Fill).kind, - None => paint.kind.clone(), - } - } - Err(_) => paint.kind.clone(), - }, - None => paint.kind.clone(), - } + image_pending = true; + let target = target.clone(); + app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| { + let Ok(bytes) = std::fs::read(&p) else { return }; + let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return }; + if let Some(mut paint) = current_paint(app, &target) { + paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind; + write(app, &target, &paint, false); + } + }); + paint.kind.clone() } 6 => match pattern_sources(app, &target).first() { Some((id, _)) => Paint::pattern(id.0).kind, @@ -495,7 +493,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } } }; - write(app, &target, &paint, false); + if !image_pending { + write(app, &target, &paint, false); + } } }); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 480824d0c..48758b37f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -136,6 +136,51 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { } } +/// Methods that need no frame drawn (#269): a hidden window answers them too. +const NO_FRAME: &[&str] = &["engine.execute", "engine.commands", "document.inspect", "node.get", "window.list", "ui.render"]; + +/// Whether window `id` draws no frames now: minimised, or covered by other windows (on macOS also +/// on another Space). +fn hidden(ctx: &Context, id: u64) -> bool { + ctx.input(|i| i.raw.viewports.get(&crate::windows::viewport(id)).and_then(egui::ViewportInfo::visible)) == Some(false) +} + +/// A hidden window (minimised, or covered by other windows) draws no frames, but the desktop app's +/// `logic` still runs, for the main window even while every window is hidden: it answers the +/// requests for any hidden window that need no frame, in that window's file, so an attached agent +/// can edit, inspect and save. The rest (screenshots, input, the interface's own state) wait for the +/// window to show; another window on screen is asked to draw, which the main window's frame would +/// do but doesn't while it is hidden. A request for a window that doesn't exist is an error at once. +pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { + for r in std::mem::take(&mut app.control_queue) { + if r.is_cancelled() { + continue; + } + let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); + if !app.windows.exists(target) { + let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); + continue; + } + if !hidden(ctx, target) || !NO_FRAME.contains(&r.method.as_str()) { + // Another window on screen draws only when asked: while the main window is hidden no + // frame of it does that (`drain`), so ask here. + if target != app.windows.current && !hidden(ctx, target) { + ctx.request_repaint_of(crate::windows::viewport(target)); + } + app.control_queue.push(r); + continue; + } + // Outside a frame, so outside the frame's guard: within the window's swap, a panic still + // ends in recovery copies and leaves every window its own state. + let res = crate::windows::as_window(app, target, |app| { + let mut res = Err("internal error".to_string()); + crate::windows::guard(app, ctx, |app| res = handle(app, ctx, &r.method, &r.params)); + res + }); + let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) }); + } +} + fn save_color_image(img: &egui::ColorImage, path: &str) -> Result<(), String> { let [w, h] = img.size; let mut rgba = Vec::with_capacity(w * h * 4); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221ee6361..3b22380d0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -15,6 +15,39 @@ use crate::theme::{ACCENT, Tokens, regular, semibold}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Where a report goes. +const ISSUES: &str = "https://github.com/storytold/figcraft/issues"; + +/// What the desktop app says when its window couldn't open with `error` (no frame was drawn: the +/// language is the one [`i18n::set`] picked from the preferences). `backend_set`: `WGPU_BACKEND` +/// named the graphics API, so the hint is to unset it rather than to try another one. +pub fn window_failed(error: &str, backend_set: bool) -> String { + let hint = if backend_set { + tr("WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.").to_string() + } else if cfg!(windows) { + // Direct3D 12 and OpenGL are both offered, but wgpu keeps the Direct3D 12 adapter it picked + // even when its device then fails, so naming OpenGL alone can still help. + format!( + "{} + +set WGPU_BACKEND=gl +figcraft.exe", + tr("Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:") + ) + } else { + tr("Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try.").to_string() + }; + format!( + "{} + +{hint} + +{}", + trf("FigCraft couldn't open its window: {error}", &[("error", &error)]), + trf("If it keeps happening, please report it at {link} with this message.", &[("link", &ISSUES)]) + ) +} + /// A dialog window, centred. pub(crate) fn window<'a>(title: &'a str, ctx: &Context) -> egui::Window<'a> { floating(title, ctx).anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0)) @@ -163,6 +196,10 @@ pub fn show(app: &mut FigApp, ctx: &Context) { if app.services.reduce_motion() { crate::widgets::label(ui, tr("Your system asks for reduced motion: animations stay off."), t.text_secondary); } + // The browser has no files to save to. + if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.auto_save, tr("Save files automatically")) { + toggles.push("pref.autoSave"); + } for c in toggles { app.run(c, json!({})); } @@ -464,15 +501,17 @@ fn color_profile_dialog(app: &mut FigApp, ctx: &Context) { /// Save the whole file as `.fig`; the toast says how much was left out. fn save_fig(app: &mut FigApp, name: &str) { - let Some(p) = app.services.pick_save(tr("Export"), &format!("{name}.fig"), &["fig"]) else { return }; - let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); - if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { - let left_out = r["warnings"].as_array().map_or(0, Vec::len); - app.toast(match left_out { - 0 => trf("Exported {name}", &[("name", &file)]), - n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]), - }); - } + let filename = format!("{name}.fig"); + app.pick_save(tr("Export"), &filename, &["fig"], |app, p| { + let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); + if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { + let left_out = r["warnings"].as_array().map_or(0, Vec::len); + app.toast(match left_out { + 0 => trf("Exported {name}", &[("name", &file)]), + n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]), + }); + } + }); } /// What the Export dialog was asked to do. diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 1c0c6a377..ade5df297 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -318,11 +318,13 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { // The suggested name keeps only the file's own name (a layer's folders apply to folders). [one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)), _ => { - let Some(dir) = app.services.pick_folder(tr("Export")) else { return }; - if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "dir": dir.to_string_lossy(), "assets": assets})) { - let n = r["files"].as_array().map_or(0, Vec::len); - app.toast(trn("Exported {n} files", n, &[])); - } + let ids = ids.to_vec(); + app.pick_folder(tr("Export"), move |app, dir| { + if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "dir": dir.to_string_lossy(), "assets": assets})) { + let n = r["files"].as_array().map_or(0, Vec::len); + app.toast(trn("Exported {n} files", n, &[])); + } + }); } } } @@ -330,14 +332,14 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { /// Export `ids` together as one file with `setting`, through a save dialog suggesting `file`. pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting, file: &str) { let ext = setting.format.ext(); - let Some(p) = app.services.pick_save(tr("Export"), file, &[ext]) else { return }; - let path = p.to_string_lossy().to_string(); let mut params = setting_params(setting); params["ids"] = json!(ids); - params["path"] = json!(path); - if app.run("file.export", params).is_some() { - app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); - } + app.pick_save(tr("Export"), file, &[ext], move |app, p| { + params["path"] = json!(p.to_string_lossy()); + if app.run("file.export", params).is_some() { + app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); + } + }); } /// The file name a one-off export of `ids` suggests: the first layer's name (or `fallback`) with 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 000000000..3d536a017 --- /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/gradient_shorthand_tests.rs b/crates/ui-egui/src/gradient_shorthand_tests.rs new file mode 100644 index 000000000..db368f8e6 --- /dev/null +++ b/crates/ui-egui/src/gradient_shorthand_tests.rs @@ -0,0 +1,21 @@ +//! The control channel takes the documented gradient shorthand, as MCP does (#252). + +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +#[test] +fn engine_execute_takes_the_gradient_shorthand() { + let mut u = Ui::new(Session::new()); + let exec = |u: &mut Ui, command: &str, params: serde_json::Value| { + crate::control::handle(&mut u.app, &u.ctx.clone(), "engine.execute", &json!({"command": command, "params": params})) + }; + let r = exec(&mut u, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].clone(); + let short = json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]}); + exec(&mut u, "node.set", json!({"ids": [r], "fills": [short]})).unwrap(); + let n = exec(&mut u, "node.get", json!({"id": r, "depth": 0})).unwrap(); + let f = &n["fills"][0]; + assert_eq!((f["type"].clone(), f["kind"].clone(), f["width"].clone()), (json!("gradient"), json!("linear"), json!([0.0, 1.5])), "{n}"); + assert_eq!(f["stops"].as_array().map(Vec::len), Some(2), "{n}"); +} diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 9b57cb0a1..e6c78947b 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 dbc585067..fc148e81e 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 6c8bf0b64..1c3235ddb 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 f2091f019..af6519921 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/i18n.rs b/crates/ui-egui/src/i18n.rs index 3b8e1b1a3..46ccd9097 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -13,8 +13,13 @@ use std::sync::atomic::{AtomicUsize, Ordering}; /// One catalog entry. pub enum Text { Plain(&'static str), - /// Plural forms: `one` (when the language has it) and `other`. - Plural(Option<&'static str>, &'static str), + /// Optional language-specific plural forms, with `other` as the fallback. + Plural { + one: Option<&'static str>, + few: Option<&'static str>, + many: Option<&'static str>, + other: &'static str, + }, } /// A language's catalog (generated from `locales/.toml`). @@ -104,7 +109,7 @@ fn lookup(lang: usize, key: &str) -> Option<&'static Text> { /// two translations gets a context after `|` ("Fill|sizing"); the English catalog maps it back. pub fn tr(key: &str) -> &str { match lookup(CURRENT.load(Ordering::Relaxed), key) { - Some(Text::Plain(s) | Text::Plural(_, s)) => s, + Some(Text::Plain(s) | Text::Plural { other: s, .. }) => s, None => key, } } @@ -123,26 +128,59 @@ pub fn trf(key: &str, args: &[(&str, &dyn Display)]) -> String { /// The plural form of `key` (its English "other" form) for count `n`, with `{n}` and `args` filled. pub fn trn(key: &str, n: usize, args: &[(&str, &dyn Display)]) -> String { let lang = CURRENT.load(Ordering::Relaxed); - let text = match lookup(lang, key) { - Some(Text::Plural(one, other)) => one.filter(|_| plural_one(lang, n)).unwrap_or(other), - Some(Text::Plain(s)) => s, - None => key, - }; + let text = counted(lang, key, n); let mut all: Vec<(&str, &dyn Display)> = vec![("n", &n)]; all.extend_from_slice(args); fill(text, &all) } +/// Pick a plural using the catalog that supplies the text: an English fallback keeps English's +/// rule, even when the selected language is Russian. +fn counted(lang: usize, key: &str, n: usize) -> &str { + chain(lang) + .find_map(|l| { + find(l.ui, key).map(|text| match text { + Text::Plain(s) => *s, + Text::Plural { one, few, many, other } => match plural(l.code, n) { + Plural::One => one.unwrap_or(other), + Plural::Few => few.unwrap_or(other), + Plural::Many => many.unwrap_or(other), + Plural::Other => other, + }, + }) + }) + .unwrap_or(key) +} + +#[derive(Debug, PartialEq, Eq)] +enum Plural { + One, + Few, + Many, + Other, +} + +/// CLDR integer rules. Existing one/other catalogs may omit the extra forms. +fn plural(code: &str, n: usize) -> Plural { + match code { + "ru" if n % 10 == 1 && n % 100 != 11 => Plural::One, + "ru" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few, + "ru" => Plural::Many, + "ja" | "ko" | "zh-Hans" => Plural::Other, + "fr" | "pt-BR" if n <= 1 => Plural::One, + _ if n == 1 => Plural::One, + _ => Plural::Other, + } +} + +// CLDR's million-count "many" in French, Spanish and Portuguese still reads their existing +// "other" wording. Only Russian currently supplies separate few/many forms. + /// Whether count `n` takes the "one" form: 0 and 1 in French and Brazilian Portuguese, never in /// Japanese, Korean and Chinese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules). -// shortcut: CLDR's "many" (French, Spanish and Portuguese millions) reads as "other" here; add it -// when a language needs a third form. +#[cfg(test)] fn plural_one(lang: usize, n: usize) -> bool { - match LANGS.get(lang).map(|l| l.code) { - Some("ja" | "ko" | "zh-Hans") => false, - Some("fr" | "pt-BR") => n <= 1, - _ => n == 1, - } + plural(LANGS.get(lang).map_or("en", |l| l.code), n) == Plural::One } /// Replace each `{name}` in `text` with its argument, in one pass (an argument's own braces stay). @@ -186,8 +224,8 @@ pub fn menu_item<'a>(id: &str, english: &'a str) -> Cow<'a, str> { fn label<'a>(menu: bool, id: &str, english: &'a str) -> Cow<'a, str> { let text = chain(CURRENT.load(Ordering::Relaxed)).find_map(|l| menu.then(|| find(l.menu, id)).flatten().or_else(|| find(l.commands, id))); match text { - Some(Text::Plain(s) | Text::Plural(_, s)) if english.ends_with('…') && !s.ends_with('…') => Cow::Owned(format!("{s}…")), - Some(Text::Plain(s) | Text::Plural(_, s)) => Cow::Borrowed(s), + Some(Text::Plain(s) | Text::Plural { other: s, .. }) if english.ends_with('…') && !s.ends_with('…') => Cow::Owned(format!("{s}…")), + Some(Text::Plain(s) | Text::Plural { other: s, .. }) => Cow::Borrowed(s), None => Cow::Borrowed(english), } } diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 768434e34..7f504fff9 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -216,7 +216,7 @@ fn placeholders(s: &str) -> BTreeSet<&str> { fn forms(t: &Text) -> Vec<&'static str> { match t { Text::Plain(s) => vec![s], - Text::Plural(one, other) => one.iter().copied().chain([*other]).collect(), + Text::Plural { one, few, many, other } => one.iter().chain(few).chain(many).copied().chain([*other]).collect(), } } @@ -265,10 +265,10 @@ fn catalogs_have_known_keys_and_matching_placeholders() { for (i, form) in forms(t).iter().enumerate() { let got = placeholders(form); // A "one" form may spell the count out ("una capa"). - let ok = if matches!(t, Text::Plural(Some(_), _)) && i == 0 { got.is_subset(&want) } else { got == want }; + let ok = if matches!(t, Text::Plural { one: Some(_), .. }) && i == 0 { got.is_subset(&want) } else { got == want }; assert!(ok, "{}: {k:?} has placeholders {got:?}, English has {want:?}", lang.code); } - assert_eq!(matches!(t, Text::Plural(..)), matches!(src, Text::Plural(..)), "{}: {k:?} plural or not as in English", lang.code); + assert_eq!(matches!(t, Text::Plural { .. }), matches!(src, Text::Plural { .. }), "{}: {k:?} plural or not as in English", lang.code); } for (id, t) in lang.commands.iter().chain(lang.menu) { assert!(ids.contains(id), "{}: unknown command id {id:?}", lang.code); @@ -308,7 +308,14 @@ fn system_languages_pick_the_first_one_with_a_catalog() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; assert_eq!(code("system", Some("fr-CA")), "fr"); assert_eq!(code("system", Some("de_CH.UTF-8")), "de"); + assert_eq!(code("system", Some("cs_CZ.UTF-8")), "cs"); + assert_eq!(code("system", Some("cs-CZ,en-US")), "cs"); + assert_eq!(code("cs", None), "cs"); + assert_eq!(LANGS[resolve("cs", None)].name, "Čeština"); + assert!(LANGS[resolve("cs", None)].draft); assert_eq!(code("system", Some("pt-BR")), "pt-BR"); + assert_eq!(code("system", Some("tr_TR.UTF-8")), "tr"); + assert_eq!(code("system", Some("id_ID.UTF-8")), "id"); // Portugal's Portuguese and a bare "pt" read the Brazilian catalog. assert_eq!(code("system", Some("pt-PT")), "pt-BR"); assert_eq!(code("pt", None), "pt-BR"); @@ -355,8 +362,10 @@ fn french_and_portuguese_count_zero_as_singular() { for code in ["fr", "pt-BR"] { assert!(one(code, 0) && one(code, 1) && !one(code, 2), "{code}"); } - for code in ["en", "es", "de"] { - assert!(!one(code, 0) && one(code, 1) && !one(code, 2), "{code}"); + for code in ["en", "es", "de", "cs"] { + for n in [0, 1, 2, 3, 4, 5, 21] { + assert_eq!(one(code, n), n == 1, "{code}: {n}"); + } } } @@ -369,7 +378,7 @@ fn japanese_korean_and_chinese_have_no_plural_forms() { for code in ["ja", "ko", "zh-Hans"] { let lang = LANGS.iter().position(|l| l.code == code).unwrap(); assert!(!plural_one(lang, 0) && !plural_one(lang, 1) && !plural_one(lang, 2), "{code}"); - let ones: Vec<_> = LANGS[lang].ui.iter().filter(|(_, t)| matches!(t, Text::Plural(Some(_), _))).map(|(k, _)| k).collect(); + let ones: Vec<_> = LANGS[lang].ui.iter().filter(|(_, t)| matches!(t, Text::Plural { one: Some(_), .. })).map(|(k, _)| k).collect(); assert!(ones.is_empty(), "{code}: plural entries give only `other`: {ones:?}"); } } @@ -397,6 +406,50 @@ fn the_key_scanner_reads_rust_literals() { assert_eq!(literals(src), vec!["A \"b\"", "C {n}", "DE"]); } +#[test] +fn russian_language_tags_and_integer_plurals() { + let ru = LANGS.iter().position(|l| l.code == "ru").unwrap(); + assert_eq!(LANGS[ru].name, "Русский"); + for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "RU", "ru-BY", "ru-Cyrl-RU"] { + assert_eq!(resolve(tag, None), ru, "{tag}"); + assert_eq!(resolve("system", Some(tag)), ru, "{tag}"); + } + assert_eq!(resolve("system", Some("nl,ru-RU,en-US")), ru); + for n in [1, 21, 31, 101, 1_001] { + assert_eq!(plural("ru", n), Plural::One, "{n}"); + assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слой")); + } + for n in [2, 3, 4, 22, 23, 24, 102, 104, 1_002] { + assert_eq!(plural("ru", n), Plural::Few, "{n}"); + assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слоя")); + } + for n in [0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 111, 112, 114, 1_000_000] { + assert_eq!(plural("ru", n), Plural::Many, "{n}"); + assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слоёв")); + } + for (key, text) in LANGS[ru].ui { + if let Text::Plural { one, few, many, .. } = text { + assert!(one.is_some() && few.is_some() && many.is_some(), "{key}: missing Russian plural forms"); + } + } +} + +#[test] +fn plural_fallback_uses_the_language_of_its_catalog() { + let la = LANGS.iter().position(|l| l.code == "es-419").unwrap(); + assert_eq!(counted(la, "{n} layers", 1), "{n} capa"); + assert_eq!(counted(la, "{n} layers", 21), "{n} capas"); + // Unsupported optional categories use other; existing one/other languages are unchanged. + for code in ["en", "es", "de"] { + for n in [0, 2, 11, 21, 1_000_000] { + assert_eq!(plural(code, n), Plural::Other, "{code}: {n}"); + } + assert_eq!(plural(code, 1), Plural::One, "{code}"); + } + assert_eq!(counted(0, "{n} layers", 21), "{n} layers"); + assert_eq!(counted(0, "unknown {n}", 21), "unknown {n}"); +} + #[test] fn the_language_preference_takes_language_codes() { let mut s = figcraft_engine::Session::new(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 726f62f44..867b08944 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/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 2d4e57579..c61699b5f 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -212,6 +212,9 @@ fn a_finished_background_render_wakes_the_ui() { skip: vec![], solo: None, proof: false, + uid: 0, + zoom_preview: false, + at: web_time::Instant::now(), }); let start = Instant::now(); while wakes.load(Ordering::SeqCst) == 0 { @@ -288,3 +291,72 @@ fn an_animated_gif_wakes_only_the_presenter_and_only_at_its_frames() { h.app.ui.present = None; h.assert_idle("the presenter closed"); } + +#[test] +fn a_hidden_window_answers_requests_that_need_no_frame() { + // #269: minimised or covered, the window draws no frames, but the app's logic still runs: it + // answers edits, inspection and saves; what needs a frame waits for the window to show. + let dir = std::env::temp_dir().join(format!("figcraft-hidden-save-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("hidden.figcraft"); + let (mut h, _) = document(); + h.settle(); + let (tx, rx) = std::sync::mpsc::channel(); + let request = |id: u64, method: &str, params: Value| ControlRequest { + id: json!(id), + method: method.into(), + params, + reply: tx.clone(), + cancelled: Default::default(), + }; + h.app.control_queue.push(request(1, "engine.execute", json!({"command": "file.save", "params": {"path": path.to_string_lossy()}}))); + h.app.control_queue.push(request(2, "ui.inspect", json!({}))); + let mut hidden = RawInput::default(); + hidden.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() }); + let app = &mut h.app; + let _ = h.ctx.run_logic(&hidden, |ctx| crate::control::drain_hidden(app, ctx)); + let saved = rx.try_recv().unwrap(); + assert_eq!(saved.id, json!(1)); + assert!(saved.result.is_ok() && path.exists(), "saved while hidden: {:?}", saved.result); + assert!(rx.try_recv().is_err(), "the interface's state waits for a frame"); + // Shown again: the next frame answers it. + h.frame(vec![]); + assert_eq!(rx.try_recv().unwrap().id, json!(2)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn auto_save_writes_a_changed_file_then_rests() { + let dir = std::env::temp_dir().join(format!("figcraft-auto-save-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("auto.figcraft").to_string_lossy().to_string(); + let (mut h, _) = document(); + h.run("file.save", json!({"path": path})); + h.run("pref.autoSave", json!({"on": true})); + h.assert_idle("a saved file"); + h.run("shape.rectangle", json!({"x": 500, "y": 300, "width": 40, "height": 40})); + assert!(h.frame(vec![]) <= Duration::from_secs_f64(crate::AUTO_SAVE_SECS), "wakes up to save it"); + assert!(h.app.session.ds().unwrap().is_dirty(), "not saved at once"); + // Another change restarts the wait. + h.time += crate::AUTO_SAVE_SECS - 0.5; + h.run("shape.rectangle", json!({"x": 600, "y": 300, "width": 40, "height": 40})); + h.frame(vec![]); + h.time += 1.0; + h.frame(vec![]); + assert!(h.app.ui.auto_saving.is_empty() && h.app.session.ds().unwrap().is_dirty(), "waits for a pause in the changes"); + h.time += crate::AUTO_SAVE_SECS; + // The file is encoded on a thread of its own, then written. + let start = Instant::now(); + while h.app.session.ds().unwrap().is_dirty() { + assert!(start.elapsed() < Duration::from_secs(20), "saved"); + let delay = h.frame(vec![]); + assert!(h.app.ui.auto_saving.is_empty() || delay <= Duration::from_millis(100), "looks again for the file being encoded"); + std::thread::sleep(Duration::from_millis(5)); + } + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + let ds = t.ds().unwrap(); + assert!(ds.doc.page_content_bounds(ds.page).unwrap().x1 >= 640.0, "the new rectangles are in the file"); + h.assert_idle("an auto-saved file"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 956709f45..959a7a04b 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -111,7 +111,8 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { ui.painter().rect_filled(ir, CornerRadius::same(6), t.hover); } icons::paint(ui.painter(), Rect::from_center_size(ir.center(), vec2(16.0, 16.0)), icon, if active { t.accent_text } else { t.icon }); - ui.painter().text(pos2(r.center().x, y + 38.0), Align2::CENTER_CENTER, label, regular(10.0), t.text); + let shown = crate::widgets::elide_font(ui, label, (r.width() - 6.0).max(0.0), regular(10.0)); + ui.painter().text(pos2(r.center().x, y + 38.0), Align2::CENTER_CENTER, shown, regular(10.0), t.text); if resp.clicked() { match tab { Some(tb) => { @@ -215,64 +216,77 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let mut move_to: Option<(NodeId, String)> = None; // Pages are dragged up and down the list to reorder them. let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); - for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); - reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); - a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); - let r = r.shrink2(vec2(8.0, 0.0)); - a11y::ring_around(ui, r, &resp); - let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id); - if divider && !renaming { - // A divider is a line between pages: it opens nothing and takes no layers. - ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border)); - } else if resp.hovered() || id == cur { - ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); - } - if dragging.is_some() - && id != cur - && !divider - // The gap between rows counts too. - && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) - { - ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); - if ui.input(|i| i.pointer.any_released()) { - move_to = Some((id, name.clone())); + // A long list scrolls in its own area, so the layers keep their room below it. + let max_h = (ui.available_height() * 0.4).max(3.0 * ROW); + let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { + for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); + reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); + let r = r.shrink2(vec2(8.0, 0.0)); + a11y::ring_around(ui, r, &resp); + let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id); + if divider && !renaming { + // A divider is a line between pages: it opens nothing and takes no layers. + ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border)); + } else if resp.hovered() || id == cur { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - } - 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) }) + if dragging.is_some() + && id != cur + && !divider + // The gap between rows counts too. + && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) { - rename_done = Some((id, new)); + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); + if ui.input(|i| i.pointer.any_released()) { + move_to = Some((id, name.clone())); + } } - } else if !divider { - let font = if id == cur { semibold(11.0) } else { regular(11.0) }; - crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); - } - if resp.clicked() && !divider { - select = Some(id); - } - 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"))); - } - crate::right::context_popup(&resp).show(|ui| { - if ui.button(tr("Rename page")).clicked() { + 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)); + 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 { + let font = if id == cur { semibold(11.0) } else { regular(11.0) }; + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); + } + if resp.clicked() && !divider { + select = Some(id); + } + if resp.double_clicked() { app.ui.rename_page = Some((id, name.clone())); - ui.close(); + // 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)); } - if ui.button(tr("Duplicate page")).clicked() { - app.run("page.duplicate", json!({"id": id})); - ui.close(); - } - if ui.button(tr("Delete page")).clicked() { - app.run("page.delete", json!({"id": id})); - ui.close(); - } - }); + crate::right::context_popup(&resp).show(|ui| { + if ui.button(tr("Rename page")).clicked() { + app.ui.rename_page = Some((id, name.clone())); + ui.close(); + } + if ui.button(tr("Duplicate page")).clicked() { + app.run("page.duplicate", json!({"id": id})); + ui.close(); + } + if ui.button(tr("Delete page")).clicked() { + app.run("page.delete", json!({"id": id})); + ui.close(); + } + }); + } + reorder.finish(ui) + }); + // A drop outside the list's visible area (and the row gap at its edges) isn't on a page row, + // even one scrolled out of view. + if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| moved.inner_rect.expand2(vec2(0.0, 3.0)).contains(p)) { + moved.inner = None; + move_to = None; } - if let Some((from, to)) = reorder.finish(ui) + if let Some((from, to)) = moved.inner && let Some((id, ..)) = pages.get(from) { app.run("page.move", json!({"id": id, "index": to})); @@ -295,6 +309,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") @@ -677,7 +696,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.add_space(16.0); let hint = if filter.is_empty() { tr("Add a frame (F) or a shape (R) to get started") } else { tr("No matching layers") }; - crate::widgets::label(ui, hint, t.text_secondary); + ui.add(egui::Label::new(egui::RichText::new(hint).font(regular(11.0)).color(t.text_secondary)).wrap()); }); } // Dragging near the list's top or bottom edge scrolls it, faster closer to the edge. diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index e1fdecc40..43a8b76ea 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,108 @@ 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); } + +/// The page opened by a click on each spot from just under the pages header down to `to`. +fn pages_down_to(u: &mut Ui, to: f32) -> Vec { + let mut opened = Vec::new(); + for y in (190..to as i32).step_by(8) { + click(u, Pos2::new(150.0, y as f32), Modifiers::NONE); + opened.push(u.app.session.page().unwrap()); + } + opened +} + +#[test] +fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { + let mut s = Session::new(); + let first = s.page().unwrap(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + for _ in 0..59 { + s.execute("page.add", &json!({})).unwrap(); + } + let pages = s.doc().unwrap().pages.clone(); + let last = *pages.last().unwrap(); + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + // The layers list stays on screen under 60 pages. + let layer = u.row_at(rect); + // The wheel over the pages list scrolls it to its last page. + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -400.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + for _ in 0..10 { + u.frame(vec![Event::PointerMoved(Pos2::new(150.0, 200.0)), wheel.clone()]); + } + for _ in 0..30 { + u.frame(vec![]); + } + let opened = pages_down_to(&mut u, layer.y - 60.0); + assert!(opened.contains(&last), "the last page can be reached"); + let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap(); + assert!(top > 30, "the list scrolled past its first pages (top row: page {top})"); +} + +#[test] +fn a_page_dropped_below_the_pages_list_stays_where_it_was() { + let mut s = Session::new(); + let first = s.page().unwrap(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let second = id(s.execute("page.add", &json!({})).unwrap()); + for _ in 0..58 { + s.execute("page.add", &json!({})).unwrap(); + } + let pages = s.doc().unwrap().pages.clone(); + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let (row, layer) = (page_row(&mut u, second, first), u.row_at(rect)); + // Released over the layers, where pages scrolled out of view would be: no page row is there. + u.drag_to(row, layer); + u.button(layer, false); + assert_eq!(u.app.session.doc().unwrap().pages, pages, "the pages keep their order"); +} + +/// 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/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index 652522d59..4f5c6af80 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -100,9 +100,12 @@ fn tab_on_the_last_row_commits_and_ends() { #[test] fn cmd_r_renames_the_selection_and_opens_its_parents() { - // Cmd+R on macOS; Ctrl+R elsewhere, where egui sets both `ctrl` and `command`. + // Cmd+R on macOS; Ctrl+R elsewhere, where egui sets both `ctrl` and `command`. On macOS Ctrl + // comes without `command` and Ctrl+R isn't a shortcut. + let cmd = Modifiers::MAC_CMD | Modifiers::COMMAND; let ctrl = Modifiers { ctrl: true, command: true, ..Default::default() }; - for (k, held) in [Modifiers::MAC_CMD | Modifiers::COMMAND, ctrl].into_iter().enumerate() { + let keys: &[Modifiers] = if cfg!(target_os = "macos") { &[cmd] } else { &[cmd, ctrl] }; + for (k, &held) in keys.iter().enumerate() { let (mut u, _, frame, child, _) = setup(); u.app.session.execute("select.set", &json!({"ids": [child]})).unwrap(); u.frame(vec![]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f98..3fb41f8ca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,9 +63,14 @@ pub mod versions; pub mod widgets; pub mod windows; +#[cfg(test)] +mod theme_startup_tests; + #[cfg(test)] mod a11y_tests; #[cfg(test)] +mod async_dialog_tests; +#[cfg(test)] mod branch_ui_tests; #[cfg(test)] mod canvas_drag_tests; @@ -78,12 +83,16 @@ 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; #[cfg(test)] mod gap_handle_tests; #[cfg(test)] +mod gradient_shorthand_tests; +#[cfg(test)] mod home_recents_tests; #[cfg(test)] mod home_tests; @@ -122,6 +131,8 @@ mod panel_reorder_tests; #[cfg(test)] mod panel_width_tests; #[cfg(test)] +mod pen_mode_tests; +#[cfg(test)] mod perf_tests; #[cfg(test)] mod plugin_panel_tests; @@ -144,18 +155,32 @@ mod shortcut_keys_tests; #[cfg(test)] mod shortcuts_tests; #[cfg(test)] +mod style_folder_rename_tests; +#[cfg(test)] mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod variables_export_ui_tests; +#[cfg(test)] +mod variables_rename_tests; +#[cfg(test)] +mod vector_panel_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; use std::collections::BTreeSet; +use std::future::Future; use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context as TaskContext, Poll, Wake, Waker}; use figcraft_doc::NodeId; use figcraft_engine::Session; @@ -165,15 +190,24 @@ pub use control::{ControlReply, ControlRequest}; /// Platform services the app crate provides (file dialogs, clipboard, URLs). pub trait Services { - fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> Option { - None + fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(None)) } - fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> Option { - None + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(None)) + } + /// The platform saves files by handing them over as downloads (the browser), not through + /// save dialogs. + fn downloads(&self) -> bool { + false + } + /// Hand `bytes` over as a download named `name`; false when nothing was delivered. + fn download(&mut self, _name: &str, _bytes: &[u8]) -> bool { + false } /// A folder to write several files into. - fn pick_folder(&mut self, _title: &str) -> Option { - None + fn pick_folder(&mut self, _title: &str) -> DialogFuture { + Box::pin(std::future::ready(None)) } fn clipboard_set_text(&mut self, _s: &str) {} fn clipboard_get_text(&mut self) -> Option { @@ -209,10 +243,55 @@ pub trait Services { } } +pub type DialogFuture = Pin>>>; +type DialogDone = Box; + +/// A native dialog in flight. The app keeps working while it is open, so its result goes to the +/// file and window it was opened for, not to whichever is in front when it closes. +struct PendingDialog { + future: DialogFuture, + /// The path picked, kept until the window that holds `doc` draws. + path: Option, + done: DialogDone, + /// The file it acts on (`None`: none, such as Open). + doc: Option, + /// The window it was opened from. + window: u64, +} + +/// Wakes the main window, which hands the result on to the window it belongs to. +struct RepaintWaker(egui::Context); +impl Wake for RepaintWaker { + fn wake(self: Arc) { + self.0.request_repaint_of(egui::ViewportId::ROOT); + } + + fn wake_by_ref(self: &Arc) { + self.0.request_repaint_of(egui::ViewportId::ROOT); + } +} + /// No-op services (tests, headless screenshots). 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, @@ -241,6 +320,14 @@ pub enum TitleBar { /// How often open documents get an autosave checkpoint (seconds). pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0; +/// With auto-save on: how long after a file last changed it is saved, and how long a failed save +/// waits before trying again (seconds). +pub const AUTO_SAVE_SECS: f64 = 3.0; +const AUTO_SAVE_RETRY_SECS: f64 = 60.0; + +/// A file being encoded for auto-save: its snapshot and, once done, its bytes. +pub type AutoSaveEncoding = std::thread::JoinHandle<(figcraft_engine::cmd::file::FileSnapshot, figcraft_engine::Result>)>; + /// How long a toast shows (seconds). const TOAST_SECS: f64 = 3.0; @@ -280,6 +367,8 @@ pub struct UiState { pub left_width: f32, pub right_width: f32, pub right_tab: RightTab, + /// The tab of the previous frame, to see the right panel change mode. + pub last_right_tab: RightTab, pub left_tab: LeftTab, pub pages_open: bool, pub expanded: BTreeSet, @@ -376,6 +465,11 @@ pub struct UiState { pub last_copy: Option, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, + /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and + /// the revisions of those files it waits on (a change restarts the wait). + pub auto_save_at: Option<(f64, u64)>, + /// Files being encoded for auto-save on threads of their own (tab `uid`). + pub auto_saving: Vec<(u64, AutoSaveEncoding)>, /// The Assets panel shows the libraries view (publish this file, enabled libraries). pub libraries: bool, /// Keyboard navigation: the region with the keyboard, keyboard mode (F6, Tab). @@ -400,6 +494,7 @@ impl Default for UiState { left_width: PANEL_DEFAULT, right_width: PANEL_DEFAULT, right_tab: RightTab::Design, + last_right_tab: RightTab::Design, left_tab: LeftTab::File, pages_open: true, expanded: BTreeSet::new(), @@ -460,6 +555,8 @@ impl Default for UiState { last_copy: None, next_autosave: 0.0, + auto_save_at: None, + auto_saving: Vec::new(), libraries: false, nav: a11y::Nav::default(), plugins_window: false, @@ -503,6 +600,10 @@ pub struct FigApp { window_requests: Vec, /// Commands waiting for a server ([`FigApp::run_background`]) and what to do with their results. waiting: Vec<(String, Done)>, + /// Native dialogs complete asynchronously so their modal event loop never re-enters winit. + pending_dialog: Option, + /// The latest UI context schedules the first poll and wakes the app when a dialog completes. + ui_context: Option, } /// What to do with the result of a command run in the background. @@ -530,6 +631,8 @@ impl FigApp { windows: windows::Windows::default(), window_requests: Vec::new(), waiting: Vec::new(), + pending_dialog: None, + ui_context: None, } } @@ -610,6 +713,19 @@ impl FigApp { } } + /// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect + /// don't draw (#307). Checked as the frame starts (the control channel) and after the panels + /// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more. + fn end_pen_outside_design(&mut self) { + if self.ui.right_tab == self.ui.last_right_tab { + return; + } + self.ui.last_right_tab = self.ui.right_tab; + if self.ui.right_tab != RightTab::Design && self.session.tool.tool == figcraft_engine::tools::ToolId::Pen { + self.run("tool.set", json!({"tool": "move"})); + } + } + /// Once a frame, after the panels: a preview nothing asked for this frame ends. fn end_unwanted_text_preview(&mut self, ctx: &egui::Context) { if !std::mem::take(&mut self.ui.text_preview_wanted) { @@ -790,12 +906,12 @@ impl FigApp { pub fn save_as_dialog(&mut self) { let name = self.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); - if let Some(p) = self.services.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"]) { + self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"], |app, p| { let ps = p.to_string_lossy().to_string(); - if self.run("file.saveAs", json!({"path": ps})).is_some() { - self.toast(i18n::trf("Saved {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); + if app.run("file.saveAs", json!({"path": ps})).is_some() { + app.toast(i18n::trf("Saved {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); } - } + }); } pub fn save(&mut self) { @@ -811,14 +927,18 @@ impl FigApp { } pub fn open_dialog(&mut self) { - if let Some(p) = - self.services.pick_open(i18n::tr("Open"), &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) - { - let ps = p.to_string_lossy().to_string(); - if self.run("file.open", json!({"path": ps})).is_some() { - self.ui.home = false; - } - } + let exts = ["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; + // Opening needs no file in front: it goes ahead even if that file closed meanwhile. + self.start_dialog( + |services| services.pick_open(i18n::tr("Open"), &exts), + false, + |app, p| { + let ps = p.to_string_lossy().to_string(); + if app.run("file.open", json!({"path": ps})).is_some() { + app.ui.home = false; + } + }, + ); } pub fn import_dialog(&mut self, place: bool) { @@ -827,9 +947,78 @@ impl FigApp { } else { &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"] }; - if let Some(p) = self.services.pick_open(if place { i18n::tr("Place image") } else { i18n::tr("Import") }, exts) { + let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; + self.pick_open(title, exts, move |app, p| { let ps = p.to_string_lossy().to_string(); - self.run("file.import", json!({"path": ps})); + app.run("file.import", json!({"path": ps})); + }); + } + + /// Pick a file to open. `done` runs with the file in front now back in front (and not at all + /// if that file closed meanwhile). + pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_dialog(|services| services.pick_open(title, exts), true, done); + } + + /// Pick where to save; `done` runs as [`FigApp::pick_open`]'s does. + pub fn pick_save(&mut self, title: &str, name: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_dialog(|services| services.pick_save(title, name, exts), true, done); + } + + /// Pick a folder; `done` runs as [`FigApp::pick_open`]'s does. + pub fn pick_folder(&mut self, title: &str, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_dialog(|services| services.pick_folder(title), true, done); + } + + /// Open a native dialog from the window drawing now, for the file in front (`pin`). + fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + if self.pending_dialog.is_some() { + return; + } + let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None }; + let future = start(self.services.as_mut()); + self.pending_dialog = Some(PendingDialog { future, path: None, done: Box::new(done), doc, window: self.windows.current }); + if let Some(ctx) = &self.ui_context { + ctx.request_repaint(); + } + } + + /// Once the dialog has a path, its continuation runs in the window that holds its file, with + /// that file in front; then the tab in front before comes back (unless the dialog opened a file). + fn poll_dialog(&mut self, ctx: &egui::Context) { + let Some(d) = &mut self.pending_dialog else { return }; + if d.path.is_none() { + let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); + let Poll::Ready(path) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; + d.path = path; + } + let (doc, origin, picked) = (d.doc, d.window, d.path.is_some()); + let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u)); + // Cancelled, or its file closed meanwhile. + if !picked || index == Some(None) { + self.pending_dialog = None; + return; + } + let window = match doc { + Some(u) => self.windows.of(u), + None if self.windows.exists(origin) => origin, + None => 0, + }; + if window != self.windows.current { + ctx.request_repaint_of(windows::viewport(window)); + return; + } + let Some(PendingDialog { path: Some(path), done, .. }) = self.pending_dialog.take() else { return }; + let before = self.session.ds().ok().map(|d| d.uid); + if let Some(Some(i)) = index { + self.session.active = i; + } + done(self, path); + if doc.is_some() + && self.session.ds().ok().map(|d| d.uid) == doc + && let Some(i) = before.and_then(|u| self.session.docs.iter().position(|d| d.uid == u)) + { + self.session.active = i; } } @@ -888,11 +1077,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() { @@ -921,15 +1112,90 @@ impl FigApp { repaint_in(ctx, AUTOSAVE_SECS); } + /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they last + /// changed (not during a gesture, a live preview or while a version is shown). The file is + /// encoded on a thread of its own, so a big one doesn't hold up the window, and written here. + fn auto_save_tick(&mut self, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + let mut failed = self.auto_save_done(); + // The browser has no files to save to. + let on = !cfg!(target_arch = "wasm32") && self.session.prefs.auto_save; + let saving = &self.ui.auto_saving; + let due = |d: &&figcraft_engine::DocState| { + d.path.is_some() && d.branch_of.is_none() && d.preview.is_none() && d.is_dirty() && !saving.iter().any(|(uid, _)| *uid == d.uid) + }; + let changes = if on { self.session.docs.iter().filter(due).map(|d| d.revision).reduce(u64::wrapping_add) } else { None }; + match changes { + None => self.ui.auto_save_at = None, + Some(changes) => { + let at = match self.ui.auto_save_at { + Some((at, c)) if c == changes && failed.is_none() => at, + _ => now + if failed.is_some() { AUTO_SAVE_RETRY_SECS } else { AUTO_SAVE_SECS }, + }; + self.ui.auto_save_at = Some((at, changes)); + if now < at || self.session.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) { + // Due but busy: look again in a second. + repaint_in(ctx, if now < at { at - now } else { 1.0 }); + } else { + self.ui.auto_save_at = None; + let files: Vec<_> = + self.session.docs.iter().filter(due).map(|d| (d.uid, figcraft_engine::cmd::file::FileSnapshot::of(d))).collect(); + for (uid, file) in files { + let encode = move || { + let bytes = file.encode(); + (file, bytes) + }; + match std::thread::Builder::new().name("figcraft-save".into()).spawn(encode) { + Ok(h) => self.ui.auto_saving.push((uid, h)), + Err(e) => { + failed.get_or_insert(e.to_string()); + self.ui.auto_save_at = Some((now + AUTO_SAVE_RETRY_SECS, changes)); + } + } + } + } + } + } + if !self.ui.auto_saving.is_empty() { + // Look again shortly for the files being encoded. + repaint_in(ctx, 0.1); + } + if let Some(e) = failed { + self.toast(i18n::trf("Couldn't save automatically: {error}", &[("error", &e)])); + } + } + + /// Write the files whose auto-save encoding finished; the first error, if one failed. + fn auto_save_done(&mut self) -> Option { + if self.ui.auto_saving.is_empty() { + return None; + } + let mut failed = None; + let done: Vec<_> = self.ui.auto_saving.extract_if(.., |(_, h)| h.is_finished()).collect(); + for (_, h) in done { + let saved = match h.join() { + Ok((snapshot, Ok(bytes))) => snapshot.save(&mut self.session, &bytes).map(|_| ()).map_err(|e| e.to_string()), + Ok((_, Err(e))) => Err(e.to_string()), + Err(_) => Err("the file couldn't be encoded".to_string()), + }; + if let Err(e) = saved { + failed.get_or_insert(e); + } + } + failed + } + /// Draw the whole UI: the main window (the other windows draw themselves). pub fn ui(&mut self, ui: &mut egui::Ui) { #[cfg(not(target_arch = "wasm32"))] let t0 = std::time::Instant::now(); let ctx = ui.ctx().clone(); + self.ui_context = Some(ctx.clone()); self.frame_stats.record(&ctx); let first = !self.fonts_installed; self.logic(&ctx); self.autosave_tick(&ctx); + self.auto_save_tick(&ctx); if first { // New fonts apply from the next frame; draw nothing until then. ctx.request_repaint(); @@ -969,6 +1235,8 @@ impl FigApp { // even if it lets go during the frame (Enter commits it), or as it began (egui drops the // focus on Escape before the frame, which cancels the field's edit and nothing else). let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx); + // A dialog that closed acts first, in the window it was opened from. + self.poll_dialog(&ctx); // Control requests run first so their results reflect this frame. control::drain(self, &ctx); windows::requests(self, &ctx); @@ -984,6 +1252,7 @@ impl FigApp { widgets::set_nudge(&ctx, self.session.prefs.nudge_small, self.session.prefs.nudge_big); // The Inspect tab measures on hover without Alt. self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; + self.end_pen_outside_design(); if self.ui.present.is_some() { present::show(self, ui); // What went wrong while presenting (a widget's plugin failing) shows over it. @@ -1086,6 +1355,7 @@ impl FigApp { variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); self.end_unwanted_text_preview(&ctx); + self.end_pen_outside_design(); dialogs::show(self, &ctx); self.toast_ui(&ctx); // A rename started after the layers panel drew (Cmd/Ctrl+R, a double-click) opens its field @@ -1181,6 +1451,9 @@ impl FigApp { figcraft_render::Work::Partial { pixels } => json!({"kind": "partial", "pixels": pixels}), figcraft_render::Work::Full => json!({"kind": "full"}), }), + "quality": if self.canvas.tex_coarse { "preview" } else { "refined" }, + "previewLatencyMs": self.canvas.preview_latency_ms, + "refineLatencyMs": self.canvas.refine_latency_ms, }, "live": live::inspect(self), "window": self.windows.current, diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 46687a685..5cd5f7239 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -444,7 +444,8 @@ fn follow_frame(painter: &egui::Painter, canvas: Rect, name: &str, fill: Color32 } /// You and the others at the top of the right panel, within `area`: click someone to follow -/// them (again to stop); the chevron lists everyone. +/// them (again to stop); right-click them to remove them from the session (when you host it or +/// own the file); the chevron lists everyone. pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let t = Tokens::get(ui.ctx()); const R: f32 = 12.0; @@ -459,6 +460,8 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let peers: Vec<(u32, String, Color32)> = live.peers.values().map(|p| (p.client_id, p.name.clone(), color(&p.color))).collect(); let follow = live.follow; let (in_session, spotlighting) = (live.status == Status::Live, live.spotlighting); + let can_remove = figcraft_engine::live::can_remove_people(&app.session); + let mut remove = None; // Room for the avatars after yours and the chevron. let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1); let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) }; @@ -496,6 +499,14 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if resp.clicked() { clicked = Some(*id); } + if can_remove { + crate::right::context_popup(&resp).show(|ui| { + if menu_item(ui, &trf("Remove {name} from session", &[("name", name)]), "") { + remove = Some(*id); + ui.close(); + } + }); + } } if more > 0 { x += STEP; @@ -539,6 +550,10 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if spotlight { app.run("collab.spotlight", json!({"on": !spotlighting})); } + // A server is asked without the window waiting for it. + if let Some(id) = remove { + app.run_background(&ui.ctx().clone(), "collab.removePerson", json!({"client": id}), Box::new(|_, _, _| {})); + } } /// The Share button's popover: the live session (start, invite link, stop or leave) and export. diff --git a/crates/ui-egui/src/pen_mode_tests.rs b/crates/ui-egui/src/pen_mode_tests.rs new file mode 100644 index 000000000..d29d637ce --- /dev/null +++ b/crates/ui-egui/src/pen_mode_tests.rs @@ -0,0 +1,116 @@ +//! Leaving Design with a pen path open (#307): the path ends as Escape ends it, the tool goes back +//! to Move, and the canvas of Prototype or Inspect takes no more pen points. Driven by the panel +//! keys and by the toolbar's mode switcher. + +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use figcraft_engine::Session; +use figcraft_engine::tools::ToolId; +use serde_json::json; + +use crate::RightTab; +use crate::layers_drag_tests::Ui; + +fn press(u: &mut Ui, key: Key, modifiers: Modifiers) { + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +fn layers(u: &Ui) -> usize { + let doc = u.app.session.doc().unwrap(); + doc.node(u.app.session.page().unwrap()).unwrap().children.len() +} + +/// The middle of the toolbar's mode button named `label`, found in the AccessKit tree. +fn mode_button(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// A pen path with two anchors still open on an empty page. +fn drawing() -> Ui { + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "pen"})); + click(&mut u, pos2(600.0, 400.0)); + click(&mut u, pos2(700.0, 450.0)); + assert_eq!(u.app.session.tool.pen.as_ref().map(|p| p.points.len()), Some(2), "the path is open"); + u +} + +#[test] +fn switching_mode_ends_the_pen_path_as_escape_does() { + let mut esc = drawing(); + press(&mut esc, Key::Escape, Modifiers::NONE); + assert!(esc.app.session.tool.pen.is_none()); + let finished = layers(&esc); + assert_eq!(finished, 1, "Escape keeps the two-anchor path"); + + // Shift+E is Prototype, Shift+D is Inspect. + for key in [Key::E, Key::D] { + let mut u = drawing(); + press(&mut u, key, Modifiers::SHIFT); + assert_ne!(u.app.ui.right_tab, RightTab::Design, "{key:?}"); + assert!(u.app.session.tool.pen.is_none(), "{key:?}: the path is finished"); + assert_eq!(u.app.session.tool.tool, ToolId::Move, "{key:?}: back to Move"); + assert_eq!(layers(&u), finished, "{key:?}: the path is kept as Escape keeps it"); + // The canvas takes no more pen points. + click(&mut u, pos2(800.0, 300.0)); + assert!(u.app.session.tool.pen.is_none(), "{key:?}: a click starts no path"); + assert_eq!(layers(&u), finished, "{key:?}: a click adds nothing"); + } +} + +#[test] +fn the_mode_switcher_ends_the_pen_path_too() { + let finished = { + let mut esc = drawing(); + press(&mut esc, Key::Escape, Modifiers::NONE); + layers(&esc) + }; + for (label, tab) in [("Prototype", RightTab::Prototype), ("Inspect", RightTab::Inspect)] { + let mut u = drawing(); + let at = mode_button(&mut u, label); + click(&mut u, at); + assert_eq!(u.app.ui.right_tab, tab, "{label}"); + assert!(u.app.session.tool.pen.is_none(), "{label}: the path is finished"); + assert_eq!(u.app.session.tool.tool, ToolId::Move, "{label}: back to Move"); + assert_eq!(layers(&u), finished, "{label}: the path is kept as Escape keeps it"); + } + // Design is already the mode while the pen draws: pressing it again leaves nothing, so the + // path goes on. + let mut u = drawing(); + let at = mode_button(&mut u, "Design"); + click(&mut u, at); + assert_eq!(u.app.ui.right_tab, RightTab::Design); + assert!(u.app.session.tool.pen.is_some(), "Design: the path goes on"); + assert_eq!(u.app.session.tool.tool, ToolId::Pen, "Design: still the pen"); +} + +#[test] +fn a_press_on_the_mode_switcher_does_not_reach_the_canvas() { + // The switcher floats over the canvas: the pen must not place an anchor under it. + for label in ["Prototype", "Design"] { + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "pen"})); + let at = mode_button(&mut u, label); + u.button(at, true); + assert!(u.app.session.tool.pen.is_none(), "{label}: the press starts no path"); + u.button(at, false); + u.frame(vec![]); + assert!(u.app.session.tool.pen.is_none(), "{label}: no path after the click"); + assert_eq!(layers(&u), 0, "{label}: the click adds nothing"); + } +} diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 0d4da40c5..481b446c3 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -124,10 +124,11 @@ fn run(app: &mut FigApp, cmd: &str, params: Value) { /// Plugins ▸ Development ▸ Import plugin from manifest…: pick a `figcraft-plugin.json`. pub fn import_dialog(app: &mut FigApp) { - let Some(p) = app.services.pick_open(tr("Import plugin from manifest"), &["json"]) else { return }; - if let Some(v) = app.run("plugin.import", json!({"path": p.to_string_lossy()})) { - app.toast(trf("Imported {name}", &[("name", &v["name"].as_str().unwrap_or(tr("the plugin")))])); - } + app.pick_open(tr("Import plugin from manifest"), &["json"], |app, p| { + if let Some(v) = app.run("plugin.import", json!({"path": p.to_string_lossy()})) { + app.toast(trf("Imported {name}", &[("name", &v["name"].as_str().unwrap_or(tr("the plugin")))])); + } + }); } /// The installed plugins at the top of the Plugins menu: a plugin with one command is an item, one diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 12a0ba62b..3ad9c54c3 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -3,12 +3,24 @@ //! until the next one arrives. //! The renderer keeps its last image ([`figcraft_render::Retained`]): a pan renders only the //! uncovered strips and an edit only the areas of the layers that changed. +//! A new zoom can't reuse that image, and the old one, re-projected, covers little of the view +//! after a zoom out (an eighth of the width from 800% to 100%). When full renders are slow, the +//! whole view is first rendered coarsely ([`PREVIEW_FACTOR`] times fewer pixels each way, kept +//! apart from the full image so pans and edits still build on it), then refined unless a newer +//! request is already waiting. use std::sync::Arc; use figcraft_color::ColorProfile; use figcraft_doc::{Document, NodeId}; use figcraft_render::{Pixmap, Retained, View, Work}; +// A monotonic clock in the browser too: preview decisions rest on how long renders take there. +use web_time::Instant; + +/// A preview has this many times fewer pixels each way than the view it stands in for. +pub const PREVIEW_FACTOR: f64 = 4.0; +/// Full renders quicker than this (about a frame) show without a preview first. +const PREVIEW_AFTER_MS: f64 = 12.0; #[derive(Clone)] pub struct Job { @@ -25,6 +37,13 @@ pub struct Job { pub solo: Option, /// Soft-proof sRGB: stripe the colours sRGB can't show. pub proof: bool, + /// The document's [`figcraft_engine::DocState::uid`]: images of other files are never shown. + pub uid: u64, + /// A coarse preview may come first when the zoom changes (not in pixel preview, whose pixels + /// are the point). + pub zoom_preview: bool, + /// When the UI asked for it: how long the image took to reach the screen. + pub at: Instant, } pub struct Done { @@ -34,6 +53,13 @@ pub struct Done { pub view: View, pub ms: f64, pub work: Work, + /// What it is an image of ([`Job::uid`], [`Job::page`]). + pub uid: u64, + pub page: NodeId, + /// A coarse preview, refined by a later image unless a newer request replaced it. + pub coarse: bool, + /// [`Job::at`]. + pub at: Instant, } /// A render of a document in `profile` as a texture image for the screen (sRGB). @@ -58,9 +84,9 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP } } -fn run(job: &Job, retained: &mut Retained) -> Option { - #[cfg(not(target_arch = "wasm32"))] - let t0 = std::time::Instant::now(); +/// Render `view` of the job (its own view, or the coarse one standing in for it). +fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option { + let t0 = Instant::now(); // The canvas is a preview: what's too small to see in detail at this zoom is approximated. let opts = figcraft_render::Options { outline_mode: job.outline, @@ -71,16 +97,86 @@ fn run(job: &Job, retained: &mut Retained) -> Option { lod: true, ..Default::default() }; - let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?; + let (view, pix, work) = retained.render(&job.doc, job.page, view, &opts)?; let mut image = screen_image(pix, job.doc.color_profile); if job.proof { mark_outside_srgb(&mut image, pix, job.doc.color_profile); } - #[cfg(not(target_arch = "wasm32"))] let ms = t0.elapsed().as_secs_f64() * 1000.0; - #[cfg(target_arch = "wasm32")] - let ms = 0.0; - Some(Done { image, view, ms, work }) + Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at }) +} + +/// `view` with [`PREVIEW_FACTOR`] times fewer pixels each way, showing at least the same area. +pub fn coarse_view(view: &View) -> View { + let shrink = |n: u32| (f64::from(n) / PREVIEW_FACTOR).ceil().max(1.0) as u32; + View { origin: view.origin, scale: view.scale / PREVIEW_FACTOR, width: shrink(view.width), height: shrink(view.height) } +} + +/// What the render thread keeps between jobs: the canvas image, the previews standing in for it +/// while the zoom changes, and what the last full render drew and took. +pub struct Renders { + full: Retained, + preview: Retained, + /// The last full render's document, page and scale. + last: Option<(u64, NodeId, f64)>, + /// How long the last render from scratch took. + full_ms: f64, + /// Previews come first only when renders from scratch take at least this long. + preview_after_ms: f64, +} + +impl Default for Renders { + fn default() -> Self { + Self::with_threshold(PREVIEW_AFTER_MS) + } +} + +impl Renders { + /// Previews when renders from scratch take at least `ms` (tests: 0, always). + pub fn with_threshold(ms: f64) -> Self { + Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms } + } + + /// Whether `job` gets a preview first: the kept image is of another zoom, page or file (so the + /// full render starts from scratch) and those have been slow. The first render has no + /// measure yet and comes without one. + fn wants_preview(&self, job: &Job) -> bool { + job.zoom_preview + && self.full_ms >= self.preview_after_ms + && self.last.is_some_and(|(uid, page, scale)| uid != job.uid || page != job.page || scale.to_bits() != job.view.scale.to_bits()) + } + + /// The coarse preview of the whole of `job`'s view, when one should come first. + pub fn preview(&mut self, job: &Job) -> Option { + if !self.wants_preview(job) { + return None; + } + let view = coarse_view(&job.view); + guarded(&mut self.preview, |r| run(job, &view, r, true)) + } + + /// The image of `job`'s view, built on the kept one where it can be. + pub fn refine(&mut self, job: &Job) -> Option { + let done = guarded(&mut self.full, |r| run(job, &job.view, r, false)); + if let Some(d) = &done { + self.last = Some((job.uid, job.page, job.view.scale)); + if d.work == Work::Full { + self.full_ms = d.ms; + } + } + done + } +} + +/// Run `f` on `retained`; after a panic, forget the image it may have left half drawn. +fn guarded(retained: &mut Retained, f: impl FnOnce(&mut Retained) -> Option) -> Option { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(retained))) { + Ok(d) => d, + Err(_) => { + retained.clear(); + None + } + } } #[cfg(not(target_arch = "wasm32"))] @@ -104,22 +200,26 @@ impl Worker { // The window that renders with it (each window has its own worker). let viewport = ctx.viewport_id(); let spawned = std::thread::Builder::new().name("figcraft-render".into()).spawn(move || { - let mut retained = Retained::default(); - while let Ok((mut seq, mut job)) = jobs.recv() { + let mut renders = Renders::default(); + let mut next = None; + loop { + let Some((mut seq, mut job)) = next.take().or_else(|| jobs.recv().ok()) else { break }; // Skip to the newest queued job. while let Ok(newer) = jobs.try_recv() { (seq, job) = newer; } - let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&job, &mut retained))); - let done = match res { - Ok(d) => d, - Err(_) => { - // Don't build on an image a panic may have left half drawn. - retained.clear(); - None + if let Some(d) = renders.preview(&job) { + if done_tx.send(d).is_err() { + break; } - }; - if let Some(d) = done + ctx.request_repaint_of(viewport); + // A newer request makes refining this one moot: it gets its own preview. + if let Ok(newer) = jobs.try_recv() { + next = Some(newer); + continue; + } + } + if let Some(d) = renders.refine(&job) && done_tx.send(d).is_err() { break; @@ -155,30 +255,44 @@ impl Worker { } } -#[cfg(target_arch = "wasm32")] -pub struct Worker { +/// The browser's worker: renders inline, one image a frame: a preview first when one is due, the +/// refined image in the next frame unless a newer request replaced the job. Built everywhere so +/// tests run it. +pub struct Inline { + ctx: egui::Context, pending: Option, - retained: Retained, + previewed: bool, + renders: Renders, } #[cfg(target_arch = "wasm32")] -impl Worker { - pub fn new(_ctx: egui::Context) -> Self { - Self { pending: None, retained: Retained::default() } +pub type Worker = Inline; + +impl Inline { + pub fn new(ctx: egui::Context) -> Self { + Self { ctx, pending: None, previewed: false, renders: Renders::default() } } pub fn submit(&mut self, job: Job) { self.pending = Some(job); + self.previewed = false; } pub fn in_flight(&self) -> bool { self.pending.is_some() } pub fn poll(&mut self) -> Option { + if !self.previewed { + self.previewed = true; + if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) { + self.ctx.request_repaint(); + return Some(d); + } + } let job = self.pending.take()?; - run(&job, &mut self.retained) + self.renders.refine(&job) } } /// Render synchronously from scratch (tests, screenshots). pub fn render_now(job: &Job) -> Option { - run(job, &mut Retained::default()) + run(job, &job.view, &mut Retained::default(), false) } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index d1244ff4f..6be287807 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/style_folder_rename_tests.rs b/crates/ui-egui/src/style_folder_rename_tests.rs new file mode 100644 index 000000000..624edfda6 --- /dev/null +++ b/crates/ui-egui/src/style_folder_rename_tests.rs @@ -0,0 +1,56 @@ +//! Renaming a folder of local styles, drawn headless with the screen reader tree on: Rename folder +//! in the folder's menu opens its name for editing. On every frame the focused node must be in the +//! AccessKit tree; the platform adapter (UI Automation on Windows) aborts the app when it isn't +//! (the same fault as #224). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; + +/// One frame with `events`, checking the focused node is in the screen reader tree. +fn checked_frame(u: &mut Ui, events: Vec, k: usize) -> Vec { + let out = u.frame_output(events); + 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); + flat(&out.shapes) +} + +/// A click with `button` at `p` and the frames that follow, each checked. +fn click(u: &mut Ui, p: Pos2, button: PointerButton) -> Vec { + for _ in 0..5 { + u.frame(vec![Event::PointerMoved(p)]); + } + let mut shapes = Vec::new(); + for (k, pressed) in [Some(true), Some(false)].into_iter().chain(std::iter::repeat_n(None, 20)).enumerate() { + let events = pressed.map(|pressed| Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + shapes = checked_frame(u, events.into_iter().collect(), k); + } + shapes +} + +#[test] +fn renaming_a_style_folder_keeps_the_focus_in_the_screen_reader_tree() { + let mut s = Session::new(); + for name in ["Brand/Primary", "Brand/Accent"] { + s.execute("style.create", &json!({"type": "paint", "name": name, "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + } + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + let shapes = checked_frame(&mut u, vec![], 0); + // The Design panel lists the local styles with nothing selected; the folder's menu renames it. + let menu = click(&mut u, text_center(&shapes, "Brand"), PointerButton::Secondary); + click(&mut u, text_center(&menu, "Rename folder"), PointerButton::Primary); + checked_frame(&mut u, vec![Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }], 0); + checked_frame(&mut u, vec![Event::Text("Theme".into())], 1); + for pressed in [true, false] { + checked_frame(&mut u, vec![Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }], 2); + } + for k in 0..5 { + checked_frame(&mut u, vec![], k + 3); + } + let names: Vec = u.app.session.doc().unwrap().styles.iter().map(|s| s.name.clone()).collect(); + assert_eq!(names, ["Theme/Primary", "Theme/Accent"]); +} diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 3a4e7b11d..7df44fa6d 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -746,7 +746,7 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { (family_resp, style_resp) }); crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()) { + if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked { set(out, &|d| { d.font_family = f.clone(); let styles = figcraft_text::family_styles(&f); @@ -1042,7 +1042,10 @@ pub fn local_styles(app: &mut FigApp, ui: &mut Ui, doc: &Document) { let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); let text_r = Rect::from_min_max(pos2(r.min.x + widgets::PAD, r.min.y + 2.0), pos2(r.max.x - widgets::PAD, r.max.y - 2.0)); if renaming.as_deref() == Some(*f) { - let (out, open) = widgets::rename_field(ui, folder_rename_id().with(*f), f, text_r, regular(11.0), false); + // Focused as it is first drawn: a focus asked for before its field is drawn + // isn't in the screen reader tree (UI Automation aborts the app, #224). + let start = ui.data_mut(|d| d.remove_temp::(folder_rename_id().with("start"))).unwrap_or(false); + let (out, open) = widgets::rename_field(ui, folder_rename_id().with(*f), f, text_r, regular(11.0), start); if let Some(name) = out { run = Some(("style.renameFolder", json!({"folder": f, "name": name}))); } @@ -1055,7 +1058,7 @@ pub fn local_styles(app: &mut FigApp, ui: &mut Ui, doc: &Document) { crate::right::context_popup(&resp).show(|ui| { if crate::right::menu_item(ui, tr("Rename folder"), "") { ui.data_mut(|d| d.insert_temp(folder_rename_id(), f.to_string())); - ui.memory_mut(|m| m.request_focus(folder_rename_id().with(*f).with("te"))); + ui.data_mut(|d| d.insert_temp(folder_rename_id().with("start"), true)); ui.close(); } }); diff --git a/crates/ui-egui/src/text_enter_tests.rs b/crates/ui-egui/src/text_enter_tests.rs index 2a0f3d228..5de0522df 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 000000000..6f2b13e24 --- /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/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 1e6e38aa1..a921cc293 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -329,6 +329,10 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) { /// toolbar region keyboard navigation moves through. fn pill(ui: &Ui, nav: &mut Nav, canvas: Rect, width: f32, t: &Tokens) -> Rect { let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0)); + // The pill takes the press and the drag over it: its buttons sense clicks only, and egui gives + // a drag the canvas under them would sense to the canvas, so the pen placed an anchor there. + // Not focusable: the keyboard walks the buttons, not the pill. + ui.interact(rect, ui.id().with("pill"), Sense::CLICK | Sense::DRAG); a11y::area(nav, Region::Toolbar, rect); a11y::container_bounds(ui, rect); let painter = ui.painter(); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 873ac0f0b..35f6df769 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -181,10 +181,14 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] let w = ui.available_width(); let resp = widgets::dropdown(ui, family.as_deref().unwrap_or(tr(MIXED)), w, None); menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = font_picker(ui, family.as_deref(), resp.clicked()) { + let pick = font_picker(ui, family.as_deref(), resp.clicked()); + if let Some(f) = pick.picked { remember_family(ui.ctx(), &f); apply_style(app, "fontFamily", json!(f)); ui.close(); + } else if let Some(f) = pick.highlighted { + // The canvas shows the family the arrow keys are on. + app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": f}))); } }); }); @@ -825,7 +829,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()); @@ -1030,6 +1065,8 @@ pub(crate) fn parse_letter_spacing(s: &str, cur: Option) -> Optio const RECENT_KEY: &str = "figcraft_recent_fonts"; const ROW: f32 = 26.0; +/// The font list's tallest (it scrolls past that). +const LIST_HEIGHT: f32 = 380.0; fn recent_families(ctx: &egui::Context) -> Vec { ctx.data_mut(|d| d.get_persisted::>(Id::new(RECENT_KEY))).unwrap_or_default() @@ -1049,14 +1086,31 @@ enum PickRow { Family(String), } -/// The searchable family list. Returns the family picked. -pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option { +/// What the family list did this frame. +#[derive(Default)] +pub(crate) struct FontPick { + /// The family clicked, or the one Enter picked. + pub picked: Option, + /// The family Up and Down are on (Enter picks it). + pub highlighted: Option, +} + +/// The searchable family list: click a family, or step through it with Up and Down and press Enter. +pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> FontPick { let t = Tokens::get(ui.ctx()); let q_id = Id::new("font_query"); + // The row Up and Down are on, and the search it is a row of. + let hi_id = Id::new("font_highlight"); let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default(); if just_opened { q.clear(); + ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)); } + // Taken before the search field sees them (it would move its cursor). + let step = ui.input_mut(|i| { + i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64 + - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64 + }); let search = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search fonts")).desired_width(ui.available_width()).font(regular(11.5))); if just_opened { search.request_focus(); @@ -1065,9 +1119,6 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) let ql = q.trim().to_lowercase(); let all = figcraft_text::family_names(); let matches: Vec = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect(); - if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - return matches.into_iter().next(); - } let mut rows: Vec = Vec::new(); if ql.is_empty() { let recent: Vec = recent_families(ui.ctx()).into_iter().filter(|f| all.iter().any(|a| a.eq_ignore_ascii_case(f))).collect(); @@ -1079,13 +1130,65 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } if matches.is_empty() { widgets::label(ui, tr("No fonts match"), t.text_tertiary); - return None; + return FontPick::default(); } rows.extend(matches.into_iter().map(PickRow::Family)); + let family = |i: usize| match rows.get(i) { + Some(PickRow::Family(f)) => Some(f.clone()), + _ => None, + }; + // A new search starts again; the first step starts from the current family. + let mut hi = ui.data(|d| d.get_temp::<(String, usize)>(hi_id)).filter(|(hq, _)| *hq == q).map(|(_, i)| i); + let stride = ROW + ui.spacing().item_spacing.y; + let mut offset = None; + if step != 0 { + let next = |i: usize| if step > 0 { i.checked_add(1) } else { i.checked_sub(1) }; + let mut from = + hi.or_else(|| rows.iter().rposition(|r| matches!(r, PickRow::Family(f) if current.is_some_and(|c| c.eq_ignore_ascii_case(f))))); + for _ in 0..step.unsigned_abs() { + let mut at = match from { + Some(i) => next(i), + None if step > 0 => Some(0), + None => rows.len().checked_sub(1), + }; + // Past the headers; at either end it stays where it was. + while let Some(i) = at.filter(|i| *i < rows.len() && family(*i).is_none()) { + at = next(i); + } + if let Some(i) = at.filter(|i| family(*i).is_some()) { + from = Some(i); + hi = Some(i); + } + } + if let Some(i) = hi { + // Scrolled just enough to show it (the list is shorter than its tallest where the + // window has less room). + let (seen, height): (f32, f32) = ui.data(|d| d.get_temp(hi_id.with("view"))).unwrap_or((0.0, LIST_HEIGHT)); + let top = i as f32 * stride; + offset = Some(seen.max(top + stride - height).min(top)); + } + } + match hi { + Some(i) => ui.data_mut(|d| { + d.insert_temp(hi_id, (q.clone(), i)); + }), + None => ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)), + } + if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + let first = rows.iter().find_map(|r| match r { + PickRow::Family(f) => Some(f.clone()), + PickRow::Header(_) => None, + }); + return FontPick { picked: hi.and_then(family).or(first), highlighted: None }; + } let mut budget = 4; let mut picked = None; - egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show_rows(ui, ROW, rows.len(), |ui, range| { - for r in rows.get(range).unwrap_or(&[]) { + let mut area = egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([false, true]); + if let Some(y) = offset { + area = area.vertical_scroll_offset(y); + } + let shown = area.show_rows(ui, ROW, rows.len(), |ui, range| { + for (i, r) in rows.iter().enumerate().skip(range.start).take(range.len()) { let w = ui.available_width(); let (rect, resp) = ui.allocate_exact_size(vec2(w, ROW), if matches!(r, PickRow::Family(_)) { Sense::click() } else { Sense::hover() }); match r { @@ -1093,7 +1196,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, *h, semibold(10.5), t.text_secondary); } PickRow::Family(f) => { - let hovered = resp.hovered(); + let hovered = resp.hovered() || hi == Some(i); if hovered { ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent); } @@ -1129,13 +1232,37 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } } }); - picked + ui.data_mut(|d| d.insert_temp(hi_id.with("view"), (shown.state.offset.y, shown.inner_rect.height()))); + FontPick { highlighted: hi.and_then(family), picked } } #[cfg(test)] 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"))); @@ -1153,6 +1280,42 @@ mod tests { assert_eq!(parse_letter_spacing("x", Some(LetterSpacing::Percent(0.0))), None); } + /// One frame of the font picker with `events`. + fn pick(ctx: &egui::Context, current: &str, opened: bool, events: Vec) -> FontPick { + let mut out = FontPick::default(); + let mut shown = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| out = font_picker(ui, Some(current), opened)); + shown.textures_delta.clear(); + out + } + + fn key(key: egui::Key) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE } + } + + #[test] + fn arrow_keys_step_through_the_families_and_enter_picks() { + // #345 + figcraft_text::scan_system_fonts(); + let all = figcraft_text::family_names(); + if all.len() < 3 { + eprintln!("fewer than three font families; skipping"); + return; + } + let ctx = egui::Context::default(); + let current = all[1].as_str(); + assert!(pick(&ctx, current, true, vec![]).highlighted.is_none()); + // The first step starts from the current family; each press is one step. + let down = pick(&ctx, current, false, vec![key(egui::Key::ArrowDown)]); + assert_eq!(down.highlighted.as_deref(), Some(all[2].as_str())); + let up = pick(&ctx, current, false, vec![key(egui::Key::ArrowUp), key(egui::Key::ArrowUp)]); + assert_eq!(up.highlighted.as_deref(), Some(all[0].as_str())); + // At the top it stays; Enter picks the family it is on, not the first. + assert_eq!(pick(&ctx, current, false, vec![key(egui::Key::ArrowUp)]).highlighted.as_deref(), Some(all[0].as_str())); + pick(&ctx, current, false, vec![key(egui::Key::ArrowDown)]); + let enter = pick(&ctx, current, false, vec![key(egui::Key::Enter)]); + assert_eq!(enter.picked.as_deref(), Some(all[1].as_str())); + } + #[test] fn axis_values_round_by_axis_width() { let axis = |min: f64, max: f64| figcraft_text::Axis { tag: "test".into(), name: "Test".into(), min, default: min, max, hidden: false }; diff --git a/crates/ui-egui/src/variables_export_ui_tests.rs b/crates/ui-egui/src/variables_export_ui_tests.rs new file mode 100644 index 000000000..40a843931 --- /dev/null +++ b/crates/ui-egui/src/variables_export_ui_tests.rs @@ -0,0 +1,93 @@ +//! Variables ▸ Export… where the platform saves by downloads (the browser, which has no save +//! dialogs): the export arrives as a download instead of doing nothing. + +use std::sync::{Arc, Mutex}; + +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::variables_panel::export_dialog; +use crate::{FigApp, NoServices, Services}; + +type Downloads = Arc)>>>; + +/// Services that save by downloads and keep them (`deliver` false: the browser refused). +struct Browser { + got: Downloads, + deliver: bool, +} + +impl Services for Browser { + fn downloads(&self) -> bool { + true + } + fn download(&mut self, name: &str, bytes: &[u8]) -> bool { + self.got.lock().unwrap().push((name.to_string(), bytes.to_vec())); + self.deliver + } +} + +fn session() -> Session { + let mut s = Session::new(); + for (cmd, p) in [ + ("variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})), + ( + "variable.create", + json!({"collection": "Theme", "name": "color/primary", "type": "color", "values": {"Light": "#0000ff", "Dark": "#ffffff"}}), + ), + ("variable.create", json!({"collection": "Theme", "name": "radius", "type": "number", "value": 4})), + ] { + s.execute(cmd, &p).unwrap(); + } + s +} + +fn browser(deliver: bool) -> (FigApp, Downloads) { + let got = Downloads::default(); + (FigApp::new(session(), Box::new(Browser { got: got.clone(), deliver })), got) +} + +fn toast(app: &FigApp) -> String { + app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +#[test] +fn json_export_downloads_what_variable_export_writes() { + let (mut app, got) = browser(true); + let expected = app.session.execute("variable.export", &json!({"format": "json"})).unwrap()["text"].as_str().unwrap().to_string(); + export_dialog(&mut app, "json"); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert!(name.ends_with(".variables.json"), "{name}"); + assert_eq!(String::from_utf8(bytes.clone()).unwrap(), expected); + let parsed: Value = serde_json::from_slice(bytes).unwrap(); + assert!(parsed.is_object()); + assert!(toast(&app).contains(name.as_str()), "{}", toast(&app)); +} + +#[test] +fn css_export_downloads_custom_properties() { + let (mut app, got) = browser(true); + export_dialog(&mut app, "css"); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert!(name.ends_with(".variables.css"), "{name}"); + let css = String::from_utf8(bytes.clone()).unwrap(); + assert!(css.contains(":root") && css.contains("--"), "{css}"); +} + +#[test] +fn a_refused_download_says_so() { + let (mut app, got) = browser(false); + export_dialog(&mut app, "json"); + assert_eq!(got.lock().unwrap().len(), 1); + assert_eq!(toast(&app), "Couldn't download the variables"); +} + +#[test] +fn platforms_with_save_dialogs_keep_them() { + // No dialog answer (cancelled) and no downloads: nothing happens, as before. + let mut app = FigApp::new(session(), Box::new(NoServices)); + export_dialog(&mut app, "json"); + assert!(app.ui.toast.is_none()); +} diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 240a51a57..667b272e9 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -47,6 +47,8 @@ pub struct Editor { collection: Option, query: String, rename: Option, + /// The rename just started: its field takes the focus in the frame it's first drawn. + rename_start: bool, /// The variable whose details (name, description, scopes) are open. details: Option, /// The group the table shows ("space" or "color/brand"); None = every variable. @@ -202,27 +204,42 @@ fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) { ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); } -/// Export this file's collections through a save dialog. -fn export_dialog(app: &mut FigApp, format: &str) { +/// Export this file's collections through a save dialog, or as a download where the platform has +/// no save dialogs (the browser). +pub(crate) fn export_dialog(app: &mut FigApp, format: &str) { let name = app.session.doc().map(|d| format!("{}.variables.{format}", d.name)).unwrap_or_else(|_| format!("variables.{format}")); - let Some(p) = app.services.pick_save(tr("Export variables"), &name, &[format]) else { return }; - if app.run("variable.export", json!({"format": format, "path": p.to_string_lossy()})).is_some() { - let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); - app.toast(trf("Exported {file}", &[("file", &file)])); + if app.services.downloads() { + let Some(r) = app.run("variable.export", json!({"format": format})) else { return }; + let text = r["text"].as_str().unwrap_or_default(); + if app.services.download(&name, text.as_bytes()) { + app.toast(trf("Exported {file}", &[("file", &name)])); + } else { + app.toast(tr("Couldn't download the variables")); + } + return; } + let format = format.to_owned(); + let ext = format.clone(); + app.pick_save(tr("Export variables"), &name, &[&ext], move |app, p| { + if app.run("variable.export", json!({"format": format, "path": p.to_string_lossy()})).is_some() { + let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); + app.toast(trf("Exported {file}", &[("file", &file)])); + } + }); } /// Import a variables JSON picked in an open dialog. fn import_dialog(app: &mut FigApp) { - let Some(p) = app.services.pick_open(tr("Import variables"), &["json"]) else { return }; - let Some(r) = app.run("variable.import", json!({"variables": p.to_string_lossy()})) else { return }; - let (added, updated) = (r["variables"].as_u64().unwrap_or(0), r["updated"].as_u64().unwrap_or(0)); - let skipped = r["warnings"].as_array().map_or(0, Vec::len); - let mut msg = trn("Imported {n} new variables, updated {updated}", added as usize, &[("updated", &updated)]); - if skipped > 0 { - msg = format!("{msg} {}", trn("({n} warnings: some entries were skipped)", skipped, &[])); - } - app.toast(msg); + app.pick_open(tr("Import variables"), &["json"], |app, p| { + let Some(r) = app.run("variable.import", json!({"variables": p.to_string_lossy()})) else { return }; + let (added, updated) = (r["variables"].as_u64().unwrap_or(0), r["updated"].as_u64().unwrap_or(0)); + let skipped = r["warnings"].as_array().map_or(0, Vec::len); + let mut msg = trn("Imported {n} new variables, updated {updated}", added as usize, &[("updated", &updated)]); + if skipped > 0 { + msg = format!("{msg} {}", trn("({n} warnings: some entries were skipped)", skipped, &[])); + } + app.toast(msg); + }); } /// Extend library `lib`'s collection `c` and show the new collection. @@ -240,15 +257,19 @@ fn rename_id(target: &Rename) -> Id { Id::new(("var_rename", target)) } -fn start_rename(ui: &Ui, st: &mut Editor, target: Rename) { - ui.memory_mut(|m| m.request_focus(rename_id(&target).with("te"))); +/// Start renaming `target`. The field isn't drawn yet (a new collection, mode or variable shows next +/// frame), and focusing it now would put a node that isn't there in the screen reader tree, which +/// AccessKit aborts on (#224): the field takes the focus when drawn. +fn start_rename(st: &mut Editor, target: Rename) { st.rename = Some(target); + st.rename_start = true; } /// The inline editor of a rename in progress: the new name once committed. The rename ends when /// the editor closes. fn rename_field(ui: &mut Ui, st: &mut Editor, target: &Rename, value: &str, rect: Rect, font: egui::FontId) -> Option { - let (out, open) = widgets::rename_field(ui, rename_id(target), value, rect, font, false); + let start = std::mem::take(&mut st.rename_start); + let (out, open) = widgets::rename_field(ui, rename_id(target), value, rect, font, start); if !open { st.rename = None; } @@ -271,7 +292,7 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, views: &[View], local: &Variables, st: Icon::Plus, tr("Create collection"), ) { - create_collection(app, ui, local, st); + create_collection(app, local, st); } egui::ScrollArea::vertical().id_salt("vars_collections").auto_shrink([false, false]).show(ui, |ui| { for view in views { @@ -332,11 +353,11 @@ fn collection_row(app: &mut FigApp, ui: &mut Ui, view: &View, c: &Collection, st } if view.editable() { if resp.double_clicked() { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Rename"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if menu_item(ui, tr("Delete collection"), "") { @@ -421,11 +442,11 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: continue; } if resp.double_clicked() { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Rename group"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if menu_item(ui, tr("Delete group"), "") { @@ -468,14 +489,14 @@ fn rename_group(app: &mut FigApp, members: &[&Variable], g: &str, new: &str, st: } } -fn create_collection(app: &mut FigApp, ui: &Ui, vars: &Variables, st: &mut Editor) { +fn create_collection(app: &mut FigApp, vars: &Variables, st: &mut Editor) { let name = unique("Collection", |n| vars.collections.iter().any(|c| c.name == n)); if let Some(id) = app.run("variable.createCollection", json!({"name": name, "modes": ["Mode 1"]})).and_then(|r| r["id"].as_str().map(str::to_string)) { st.library = None; st.collection = Some(id.clone()); - start_rename(ui, st, Rename::Collection(id)); + start_rename(st, Rename::Collection(id)); } } @@ -503,7 +524,7 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { ); ui.add_space(12.0); if widgets::button(ui, tr("Create collection"), true, true).clicked() { - create_collection(app, ui, view.vars, st); + create_collection(app, view.vars, st); } }); }); @@ -599,18 +620,18 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: } ui.painter().with_clip_rect(cell).text(name_pos, Align2::LEFT_CENTER, &m.name, regular(11.0), t.text_secondary); if resp.double_clicked() { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Rename mode"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if menu_item(ui, tr("Duplicate mode"), "") { let name = unique(&format!("{} copy", m.name), |n| coll.modes.iter().any(|x| x.name == n)); let made = app.run("variable.addMode", json!({"collection": coll.id, "name": name, "from": m.id})); if let Some(id) = made.and_then(|r| r["id"].as_str().map(str::to_string)) { - start_rename(ui, st, Rename::Mode(coll.id.clone(), id)); + start_rename(st, Rename::Mode(coll.id.clone(), id)); } ui.close(); } @@ -645,7 +666,7 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: let name = unique("Mode", |n| coll.modes.iter().any(|m| m.name == n)); if let Some(id) = app.run("variable.addMode", json!({"collection": coll.id, "name": name})).and_then(|r| r["id"].as_str().map(str::to_string)) { - start_rename(ui, st, Rename::Mode(coll.id.clone(), id)); + start_rename(st, Rename::Mode(coll.id.clone(), id)); } } ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); @@ -679,7 +700,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va } if editable { if !inherited && resp.double_clicked() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x < name_r.max.x) { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Edit variable"), "") { @@ -687,7 +708,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va ui.close(); } if !inherited && menu_item(ui, tr("Rename"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if !v.overrides.is_empty() && menu_item(ui, tr("Reset overrides"), "") { @@ -894,7 +915,7 @@ fn create_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: let params = json!({"collection": coll.id, "name": name, "type": kind.id()}); if let Some(id) = app.run("variable.create", params).and_then(|r| r["id"].as_str().map(str::to_string)) { st.query.clear(); - start_rename(ui, st, Rename::Variable(id)); + start_rename(st, Rename::Variable(id)); } ui.close(); } diff --git a/crates/ui-egui/src/variables_rename_tests.rs b/crates/ui-egui/src/variables_rename_tests.rs new file mode 100644 index 000000000..66a5e4e24 --- /dev/null +++ b/crates/ui-egui/src/variables_rename_tests.rs @@ -0,0 +1,82 @@ +//! Renames in the variables window, drawn headless with the screen reader tree on: creating a +//! collection opens its name for editing, and so does a double click on its row. On every frame +//! the focused node must be in the AccessKit tree; the platform adapter (UI Automation on Windows) +//! aborts the app when it isn't (#224). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2}; +use figcraft_engine::Session; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; + +/// The variables window open on a document without collections, with AccessKit on. +fn variables_window() -> Ui { + let mut u = Ui::new(Session::new()); + u.app.ui.variables = Some(Default::default()); + u.ctx.enable_accesskit(); + for _ in 0..10 { + u.frame(vec![]); + } + u +} + +/// One frame with `events`, checking the focused node is in the screen reader tree. +fn checked_frame(u: &mut Ui, events: Vec, k: usize) { + let out = u.frame_output(events); + 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); +} + +/// `clicks` clicks at `p` (two make a double click) and the frames that follow, each checked. +fn click_checking_focus(u: &mut Ui, p: Pos2, clicks: usize) { + for _ in 0..10 { + u.frame(vec![Event::PointerMoved(p)]); + } + let presses = [true, false].into_iter().cycle().take(clicks * 2).map(Some); + for (k, pressed) in presses.chain(std::iter::repeat_n(None, 20)).enumerate() { + let events = pressed.map(|pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + checked_frame(u, events.into_iter().collect(), k); + } +} + +/// Type `name` and press Enter (each frame checked). +fn type_name(u: &mut Ui, name: &str) { + checked_frame(u, vec![Event::Text(name.into())], 0); + for pressed in [true, false] { + checked_frame(u, vec![Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }], 1); + } + for k in 0..5 { + checked_frame(u, vec![], k + 2); + } +} + +fn collection_names(u: &Ui) -> Vec { + u.app.session.doc().unwrap().variables.collections.iter().map(|c| c.name.clone()).collect() +} + +#[test] +fn creating_a_collection_puts_its_name_in_edit_with_the_focus_in_the_screen_reader_tree() { + let mut u = variables_window(); + let shapes = flat(&u.frame_output(vec![]).shapes); + click_checking_focus(&mut u, text_center(&shapes, "Create collection"), 1); + assert_eq!(collection_names(&u), ["Collection"], "a click creates one collection"); + // The keyboard is in its name: typing renames it. + type_name(&mut u, "Brand"); + assert_eq!(collection_names(&u), ["Brand"]); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} + +#[test] +fn double_clicking_a_collection_renames_it_with_the_focus_in_the_screen_reader_tree() { + let mut u = variables_window(); + u.app.session.execute("variable.createCollection", &serde_json::json!({"name": "Spacing"})).unwrap(); + for _ in 0..10 { + u.frame(vec![]); + } + let shapes = flat(&u.frame_output(vec![]).shapes); + click_checking_focus(&mut u, text_center(&shapes, "Spacing"), 2); + // The row's text was replaced by the field, whose text is selected: typing replaces it. + type_name(&mut u, "Space"); + assert_eq!(collection_names(&u), ["Space"]); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/vector_panel.rs index 13f1ac8dd..e965da350 100644 --- a/crates/ui-egui/src/vector_panel.rs +++ b/crates/ui-egui/src/vector_panel.rs @@ -12,7 +12,7 @@ use crate::FigApp; use crate::i18n::tr; use crate::icons::Icon; use crate::right::{CAP_ART_W, cap_art, join_label, menu_item, menu_item_with, menu_popup, row}; -use crate::widgets::{self, FieldEdit, Lead, PAD}; +use crate::widgets::{self, FieldEdit, Lead}; /// Run a point command from a field: typed values are one step, scrubs one step when released. fn point_field(app: &mut FigApp, cmd: &str, edit: Option, params: impl Fn(f64) -> Value) { @@ -38,11 +38,7 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { let sel = ve.width_point.and_then(|i| Some((i, *stroke?.profile()?.points.get(i)?))); let Some((i, p)) = sel else { let t = crate::theme::Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.add_space(PAD); - widgets::label(ui, tr("Drag the stroke to add a width point"), t.text_secondary); - }); - ui.add_space(8.0); + widgets::note(ui, tr("Drag the stroke to add a width point"), t.text_secondary); return; }; let weight = stroke.map_or(1.0, |s| s.weight); @@ -85,11 +81,7 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { widgets::section_header(ui, if pts.len() > 1 { tr("Points") } else { tr("Point") }, &[], false); if pts.is_empty() { let t = crate::theme::Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.add_space(PAD); - widgets::label(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary); - }); - ui.add_space(8.0); + widgets::note(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary); return; } let col = widgets::col_width(ui.available_width()); diff --git a/crates/ui-egui/src/vector_panel_tests.rs b/crates/ui-egui/src/vector_panel_tests.rs new file mode 100644 index 000000000..d0a5e72e1 --- /dev/null +++ b/crates/ui-egui/src/vector_panel_tests.rs @@ -0,0 +1,26 @@ +//! The Design panel in vector edit mode keeps its width, so the canvas doesn't move under the +//! pointer when the panel's contents change. +use serde_json::json; + +#[test] +fn the_canvas_stays_put_entering_vector_edit_mode_and_picking_a_region() { + let (mut h, _) = crate::live_tests::Harness::new(); + h.app.session.execute("file.newFromTemplate", &json!({"template": "mobile-app"})).unwrap(); + // A wide vector with a filled area: the panel shows its long hint until something is picked. + let id = { + let doc = h.app.session.doc().unwrap(); + doc.nodes().filter(|n| n.name == "Near ridge").max_by(|a, b| a.height.total_cmp(&b.height)).map(|n| n.id).unwrap() + }; + let canvas = |h: &mut crate::live_tests::Harness| { + h.frames(4); + h.app.inspect()["canvas"]["rect"].clone() + }; + h.app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let before = canvas(&mut h); + h.app.session.execute("vector.edit.begin", &json!({})).unwrap(); + assert_eq!(canvas(&mut h), before, "the hint for an empty point selection wraps instead of widening the panel"); + h.app.session.execute("vector.edit.select", &json!({"regions": [0]})).unwrap(); + assert_eq!(canvas(&mut h), before, "picking a region doesn't move the canvas either"); + h.app.session.execute("vector.edit.tool", &json!({"tool": "width"})).unwrap(); + assert_eq!(canvas(&mut h), before, "nor does the width tool's hint"); +} 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 000000000..a77871204 --- /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 508e6fc48..db696a1ef 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/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 82da7555c..510c24f69 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -433,21 +433,74 @@ pub(crate) fn show(app: &FigApp, ctx: &Context) { } } -/// One frame of window `id`, with its state swapped in. -fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) { - let Some(k) = app.windows.list.iter().position(|w| w.id == id) else { return }; - let ctx = ui.ctx().clone(); +/// A window's state swapped into the app ([`enter`]) until [`leave`] swaps it back out. +struct Entered { + id: u64, + pane: Pane, + /// The newest open file before: files opened after it are the window's own. + newest: u64, +} + +/// Swap window `id`'s state into the app and show its file; `None` when there is no such window. +fn enter(app: &mut FigApp, id: u64) -> Option { + let k = app.windows.list.iter().position(|w| w.id == id)?; let main_active = app.session.ds().ok().map(|d| d.uid); let newest = app.session.docs.iter().map(|d| d.uid).max().unwrap_or(0); - let files_before = app.session.docs.len(); - let prefs = app.session.prefs.clone(); - let Some(w) = app.windows.list.get_mut(k) else { return }; + let w = app.windows.list.get_mut(k)?; let want = w.active; let mut pane = std::mem::take(&mut w.pane); exchange(app, &mut pane); app.windows.current = id; app.windows.main_active = main_active; show_own(app, want); + Some(Entered { id, pane, newest }) +} + +/// Files the entered window opened are its own. +fn adopt(app: &mut FigApp, e: &Entered) { + let new: Vec = app.session.docs.iter().map(|d| d.uid).filter(|u| *u > e.newest && app.windows.of(*u) == 0).collect(); + if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == e.id) { + w.docs.extend(new); + } +} + +/// Swap the entered window's state back out (with where it is on screen, when known) and the +/// main window's back in. +fn leave(app: &mut FigApp, mut e: Entered, outer: Option, inner: Option) { + let shown = app.session.ds().ok().map(|d| d.uid).filter(|u| app.windows.of(*u) == e.id); + exchange(app, &mut e.pane); + if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == e.id) { + w.active = shown.or(w.active); + w.pos = outer.map(|r| r.min).or(w.pos); + w.size = inner.map_or(w.size, |r| r.size()); + w.pane = e.pane; + } + app.windows.current = 0; + let main_active = app.windows.main_active.take(); + show_own(app, main_active); +} + +/// Run `f` as window `id` (0: the main window) outside its frame, with its state swapped in as +/// while it draws; `None` when there is no such window. For a window that draws nothing now +/// (minimized, or occluded: behind other windows or on another Space). +pub(crate) fn as_window(app: &mut FigApp, id: u64, f: impl FnOnce(&mut FigApp) -> R) -> Option { + if id == 0 { + // Between frames the app holds the main window's state. + return Some(f(app)); + } + let e = enter(app, id)?; + let r = f(app); + adopt(app, &e); + leave(app, e, None, None); + Some(r) +} + +/// One frame of window `id`, with its state swapped in. +fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let files_before = app.session.docs.len(); + let prefs = app.session.prefs.clone(); + let Some(e) = enter(app, id) else { return }; if ctx.input(|i| i.viewport().focused == Some(true)) { app.windows.focused = id; } @@ -455,25 +508,11 @@ fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) { app.logic(&ctx); // Within the swap, so a panic leaves every window its own state. guard(app, &ctx, |app| app.frame(ui)); - // Files it opened are its own. - let new: Vec = app.session.docs.iter().map(|d| d.uid).filter(|u| *u > newest && app.windows.of(*u) == 0).collect(); - if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == id) { - w.docs.extend(new); - } + adopt(app, &e); settle(app, &ctx, files_before); if app.session.prefs != prefs { repaint_others(app, &ctx); } - let shown = app.session.ds().ok().map(|d| d.uid).filter(|u| app.windows.of(*u) == id); let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect)); - exchange(app, &mut pane); - if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == id) { - w.active = shown.or(w.active); - w.pos = outer.map(|r| r.min).or(w.pos); - w.size = inner.map_or(w.size, |r| r.size()); - w.pane = pane; - } - app.windows.current = 0; - let main_active = app.windows.main_active.take(); - show_own(app, main_active); + leave(app, e, outer, inner); } diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index e3c54ee0a..1a6e91bbc 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -1,10 +1,13 @@ //! Several windows: a window holds its own tabs; closing it closes its files, asking first when //! one has unsaved changes, and the main window takes over another window instead of quitting. +use std::sync::mpsc::{Receiver, channel}; + use egui::{RawInput, ViewportEvent, ViewportId, ViewportInfo}; use figcraft_engine::Session; -use serde_json::json; +use serde_json::{Value, json}; +use crate::control::{ControlReply, ControlRequest}; use crate::{FigApp, NoServices}; /// One main-window frame, as the desktop app runs it; `close` asks the window to close. @@ -21,6 +24,23 @@ fn frame(ctx: &egui::Context, app: &mut FigApp, close: bool) { .clear(); } +/// A control request queued for the UI, and where its reply arrives. +fn request(app: &mut FigApp, method: &str, params: Value) -> Receiver { + let (reply, rx) = channel(); + app.control_queue.push(ControlRequest { id: json!(1), method: method.into(), params, reply, cancelled: Default::default() }); + rx +} + +/// The desktop app's logic as eframe runs it for the main window, with each window's visibility +/// (`hidden`: minimised or covered). While the main window is hidden too this is all that runs. +fn logic(ctx: &egui::Context, app: &mut FigApp, hidden: &[ViewportId]) { + let mut input = RawInput::default(); + for id in std::iter::once(ViewportId::ROOT).chain(app.windows.list.iter().map(|w| crate::windows::viewport(w.id))) { + input.viewports.insert(id, ViewportInfo { minimized: Some(false), occluded: Some(hidden.contains(&id)), ..Default::default() }); + } + let _ = ctx.run_logic(&input, |ctx| crate::control::drain_hidden(app, ctx)); +} + fn names(app: &FigApp) -> Vec { app.session.docs.iter().map(|d| d.doc.name.clone()).collect() } @@ -64,3 +84,69 @@ fn windows_hold_tabs_and_close_with_their_files() { assert!(!app.ui.quit, "the app keeps running"); assert_eq!(app.session.ds().unwrap().doc.name, "A"); } + +#[test] +fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { + let mut s = Session::empty(); + for n in ["A", "B"] { + s.execute("file.new", &json!({"name": n})).unwrap(); + } + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let mut app = FigApp::new(s, Box::new(NoServices)); + frame(&ctx, &mut app, false); + let w = crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap()["window"].as_u64().unwrap(); + frame(&ctx, &mut app, false); + let other = crate::windows::viewport(w); + let rect = |size: u32| json!({"window": w, "command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": size, "height": size}}); + let dirty = |app: &FigApp, name: &str| app.session.docs.iter().find(|d| d.doc.name == name).unwrap().is_dirty(); + + // The main window is on screen, the other one covered: the request to it is answered in its + // file (A), and the main window keeps showing its own (B). + let edit = request(&mut app, "engine.execute", rect(10)); + logic(&ctx, &mut app, &[other]); + assert!(edit.try_recv().expect("answered without the other window's frame").result.is_ok()); + assert!(dirty(&app, "A") && !dirty(&app, "B"), "in the other window's file"); + assert_eq!(app.session.ds().unwrap().doc.name, "B"); + assert_eq!(app.windows.current, 0); + + // Both hidden: still answered, and inspection sees the other window's file. + let inspect = request(&mut app, "document.inspect", json!({"window": w, "depth": 0})); + logic(&ctx, &mut app, &[ViewportId::ROOT, other]); + assert_eq!(inspect.try_recv().expect("answered").result.unwrap()["name"], json!("A")); + assert_eq!(app.session.ds().unwrap().doc.name, "B"); + + // What needs a frame keeps waiting for the other window; an unknown window is an error at once. + let ui = request(&mut app, "ui.inspect", json!({"window": w})); + let unknown = request(&mut app, "window.list", json!({"window": 99})); + logic(&ctx, &mut app, &[ViewportId::ROOT, other]); + assert!(ui.try_recv().is_err(), "waits for the window to show"); + assert!(unknown.try_recv().expect("answered").result.unwrap_err().contains("no window 99")); + assert_eq!(app.control_queue.len(), 1); +} + +#[test] +fn a_request_for_a_window_on_screen_wakes_it_while_the_main_window_is_hidden() { + let mut s = Session::empty(); + for n in ["A", "B"] { + s.execute("file.new", &json!({"name": n})).unwrap(); + } + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let mut app = FigApp::new(s, Box::new(NoServices)); + frame(&ctx, &mut app, false); + let w = crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap()["window"].as_u64().unwrap(); + frame(&ctx, &mut app, false); + let other = crate::windows::viewport(w); + // Which windows are asked to draw from now on (an idle window draws no frames until asked). + let woken = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let log = woken.clone(); + ctx.set_request_repaint_callback(move |info| log.lock().unwrap().push(info.viewport_id)); + + // Only the main window's logic runs while it is hidden: the request for the other window, + // which is on screen, waits for that window's frame, and that window is asked to draw it. + let ui = request(&mut app, "ui.inspect", json!({"window": w})); + logic(&ctx, &mut app, &[ViewportId::ROOT]); + assert!(ui.try_recv().is_err(), "answered in the other window's frame"); + assert!(woken.lock().unwrap().contains(&other), "the other window is asked to draw: {:?}", woken.lock().unwrap()); +} diff --git a/crates/ui-egui/src/zoom_preview_tests.rs b/crates/ui-egui/src/zoom_preview_tests.rs new file mode 100644 index 000000000..687045715 --- /dev/null +++ b/crates/ui-egui/src/zoom_preview_tests.rs @@ -0,0 +1,284 @@ +//! Coarse previews while the zoom changes (#295): a new zoom first shows the whole view coarsely, +//! then the refined image, which matches a render from scratch; pans and edits still build on the +//! kept image; previews never replace a refined image that already shows the whole view asked +//! for, and no image of another file or page goes on screen. The browser's inline worker previews +//! by the same measure as the render thread. + +use std::sync::Arc; + +use figcraft_doc::{Document, NodeId}; +use figcraft_engine::Session; +use figcraft_engine::params::id_of; +use figcraft_render::{View, Work}; +use serde_json::json; + +use super::{CanvasState, shows}; +use crate::render_worker::{Done, Inline, Job, PREVIEW_FACTOR, Renders, coarse_view, render_now}; + +/// A page of ellipses with a rectangle on top; the session and the rectangle. +fn document() -> (Session, NodeId) { + let mut s = Session::new(); + for k in 0..12 { + let k = f64::from(k); + s.execute("shape.ellipse", &json!({"x": k * 60.0, "y": k * 40.0, "width": 120, "height": 90})).unwrap(); + } + let made = s.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 300, "height": 200})).unwrap(); + let rect = id_of(&made["id"]).unwrap(); + (s, rect) +} + +/// A `w` × `h` device-pixel view at `scale` centred on page point `c`. +fn view_at(c: (f64, f64), scale: f64, w: u32, h: u32) -> View { + View { origin: (c.0 - f64::from(w) / scale / 2.0, c.1 - f64::from(h) / scale / 2.0), scale, width: w, height: h } +} + +fn job(doc: &Arc, page: NodeId, uid: u64, view: View) -> Job { + Job { + doc: doc.clone(), + page, + view, + outline: false, + outline_extras: Default::default(), + outlined: vec![], + skip: vec![], + solo: None, + proof: false, + uid, + zoom_preview: true, + at: web_time::Instant::now(), + } +} + +/// Every pixel of `a` and `b` alike. +fn same_pixels(a: &Done, b: &Done) -> bool { + a.image.size == b.image.size && a.image.pixels == b.image.pixels +} + +fn covers(outer: &View, inner: &View) -> bool { + let (o, i) = (outer.page_rect(), inner.page_rect()); + let eps = 1e-6; + o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps +} + +#[test] +fn a_zoom_out_previews_the_whole_view_then_refines_to_a_fresh_render() { + let (s, _) = document(); + let ds = s.ds().unwrap(); + let mut r = Renders::with_threshold(0.0); + let zoomed_in = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 8.0, 400, 300)); + assert!(r.preview(&zoomed_in).is_none(), "the first render has nothing to measure previews by"); + assert!(r.refine(&zoomed_in).is_some()); + // 800% → 100% about the same centre: the old image covers 1/64 of the new view. + let out = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 1.0, 400, 300)); + let p = r.preview(&out).expect("a preview first"); + assert!(p.coarse && (p.uid, p.page) == (ds.uid, ds.page)); + assert!(covers(&p.view, &out.view), "the preview shows the whole view asked for"); + let pixels = |d: &Done| d.image.size[0] * d.image.size[1]; + let fresh = render_now(&out).unwrap(); + assert!( + (pixels(&p) as f64) * PREVIEW_FACTOR * PREVIEW_FACTOR < 1.5 * pixels(&fresh) as f64, + "a sixteenth of the pixels, give or take the snapping" + ); + let refined = r.refine(&out).unwrap(); + assert!(!refined.coarse && refined.work == Work::Full); + assert!(same_pixels(&refined, &fresh), "the settled image is a render from scratch"); +} + +/// Zoom, pan and edit with `r`; whether each step was previewed, and the pan's and the edit's +/// images. +fn zoom_pan_edit(r: &mut Renders) -> (Vec, Done, Done) { + let (mut s, rect) = document(); + let (doc, page, uid) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.page, ds.uid) + }; + r.refine(&job(&doc, page, uid, view_at((300.0, 200.0), 4.0, 1280, 960))).unwrap(); + let mut previewed = vec![]; + // A zoom; the preview is kept apart from the full image. + let zoomed = job(&doc, page, uid, view_at((300.0, 200.0), 2.0, 1280, 960)); + previewed.push(r.preview(&zoomed).is_some()); + r.refine(&zoomed).unwrap(); + // A pan at that zoom. + let mut pan = zoomed; + pan.view.origin.0 += 10.0; + previewed.push(r.preview(&pan).is_some()); + let panned = r.refine(&pan).unwrap(); + // An edit there. + s.execute("node.set", &json!({"id": rect, "props": {"x": 120}})).unwrap(); + let edit = job(&s.ds().unwrap().doc, page, uid, pan.view); + previewed.push(r.preview(&edit).is_some()); + let edited = r.refine(&edit).unwrap(); + (previewed, panned, edited) +} + +#[test] +fn pans_and_edits_get_no_preview_and_build_on_the_kept_image() { + let (previewed, panned, edited) = zoom_pan_edit(&mut Renders::with_threshold(0.0)); + assert_eq!(previewed, [true, false, false], "the zoom only"); + assert!(matches!(panned.work, Work::Partial { .. }), "a pan after a previewed zoom renders strips only: {:?}", panned.work); + assert!(matches!(edited.work, Work::Partial { .. }), "an edit renders its area only: {:?}", edited.work); + // The same steps without previews: the same work and the same pixels. + let (none, panned_alone, edited_alone) = zoom_pan_edit(&mut Renders::with_threshold(f64::INFINITY)); + assert_eq!(none, [false, false, false]); + assert_eq!((panned.work, edited.work), (panned_alone.work, edited_alone.work)); + assert!(same_pixels(&panned, &panned_alone) && same_pixels(&edited, &edited_alone)); +} + +#[test] +fn previews_come_for_other_pages_and_files_but_not_in_pixel_preview_or_when_renders_are_quick() { + let (mut s, _) = document(); + let (doc, page, uid) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.page, ds.uid) + }; + let v = view_at((300.0, 200.0), 1.0, 200, 150); + let mut r = Renders::with_threshold(0.0); + r.refine(&job(&doc, page, uid, v.clone())).unwrap(); + s.execute("page.add", &json!({})).unwrap(); + let ds = s.ds().unwrap(); + assert!(r.preview(&job(&ds.doc, ds.page, uid, v.clone())).is_some(), "another page"); + assert!(r.preview(&job(&doc, page, uid + 1, v.clone())).is_some(), "another file"); + let zoom = view_at((300.0, 200.0), 2.0, 200, 150); + let mut pixel_preview = job(&doc, page, uid, zoom.clone()); + pixel_preview.zoom_preview = false; + assert!(r.preview(&pixel_preview).is_none(), "pixel preview shows real pixels only"); + let mut quick = Renders::with_threshold(f64::INFINITY); + quick.refine(&job(&doc, page, uid, v)).unwrap(); + assert!(quick.preview(&job(&doc, page, uid, zoom)).is_none(), "quick renders show without one"); +} + +#[test] +fn previews_cover_the_view_for_any_zoom_and_density() { + let (s, _) = document(); + let ds = s.ds().unwrap(); + let mut r = Renders::with_threshold(0.0); + r.refine(&job(&ds.doc, ds.page, ds.uid, view_at((0.0, 0.0), 0.37, 64, 64))).unwrap(); + let mut n = 0; + // Zooms from 2% to 6400% at 1x, 1.5x and 2x displays, odd sizes, centres off the pixel grid. + for zoom in [0.02, 0.1, 0.333, 1.0, 2.5, 8.0, 64.0] { + for ppp in [1.0, 1.5, 2.0] { + for (w, h) in [(1, 1), (3, 7), (401, 299), (1280, 720)] { + let v = view_at((123.456, -78.9), zoom * ppp, w, h); + let c = coarse_view(&v); + assert!(covers(&c, &v), "coarse view of {v:?}"); + let p = r.preview(&job(&ds.doc, ds.page, ds.uid, v.clone())).expect("a preview"); + assert!(covers(&p.view, &v), "preview of {v:?} shows {:?}", p.view); + n += 1; + } + } + } + assert_eq!(n, 84); +} + +#[test] +fn outline_mode_proofing_and_glass_preview_and_settle_like_fresh_renders() { + let (mut s, rect) = document(); + s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap(); + let ds = s.ds().unwrap(); + for (outline, proof) in [(false, false), (true, false), (false, true)] { + let mut r = Renders::with_threshold(0.0); + let mut first = job(&ds.doc, ds.page, ds.uid, view_at((250.0, 150.0), 3.0, 300, 200)); + (first.outline, first.proof) = (outline, proof); + r.refine(&first).unwrap(); + let mut next = first.clone(); + next.view = view_at((250.0, 150.0), 0.75, 300, 200); + let p = r.preview(&next).expect("a preview"); + assert!(p.coarse && covers(&p.view, &next.view)); + let d = r.refine(&next).unwrap(); + assert!(same_pixels(&d, &render_now(&next).unwrap()), "outline {outline}, proof {proof}"); + } +} + +#[test] +fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_of_its_view() { + let (s, _) = document(); + let ds = s.ds().unwrap(); + let here = Some((ds.uid, ds.page)); + let mut r = Renders::with_threshold(0.0); + let near = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 2.0, 200, 150)); + let refined = r.refine(&near).unwrap(); + let far = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 0.5, 200, 150)); + let preview = r.preview(&far).unwrap(); + + let mut c = CanvasState { want_view: Some(near.view.clone()), ..Default::default() }; + assert!(shows(&c, &refined, here)); + assert!(!shows(&c, &refined, Some((ds.uid + 1, ds.page))), "another file"); + assert!(!shows(&c, &refined, Some((ds.uid, NodeId(ds.page.0 + 1)))), "another page"); + assert!(!shows(&c, &refined, None), "no file open"); + // Showing the refined image of the view asked for: a preview arriving late stays off. + (c.tex_view, c.tex_of, c.tex_coarse) = (Some(refined.view.clone()), here, false); + assert!(!shows(&c, &preview, here)); + // Once the view asked for moves on, the preview is the better match. + c.want_view = Some(far.view.clone()); + assert!(shows(&c, &preview, here)); + // Over another preview, or a refined image of another page, it goes on. + c.want_view = Some(near.view.clone()); + c.tex_coarse = true; + assert!(shows(&c, &preview, here)); + (c.tex_coarse, c.tex_of) = (false, Some((ds.uid, NodeId(ds.page.0 + 1)))); + assert!(shows(&c, &preview, here)); +} + +#[test] +fn a_preview_replaces_a_refined_image_of_the_same_zoom_elsewhere_on_the_page() { + // 800 × 600: A at 100% from the page origin, B at 800% about its top-left, A' back at 100% + // about B's bottom-right corner. A shows 100 × 75 of A', 1/64 of it. + let (s, _) = document(); + let ds = s.ds().unwrap(); + let here = Some((ds.uid, ds.page)); + let view = |origin, scale| View { origin, scale, width: 800, height: 600 }; + let a = job(&ds.doc, ds.page, ds.uid, view((0.0, 0.0), 1.0)); + let b = job(&ds.doc, ds.page, ds.uid, view((0.0, 0.0), 8.0)); + let a2 = job(&ds.doc, ds.page, ds.uid, view((-700.0, -525.0), 1.0)); + let mut r = Renders::with_threshold(0.0); + let refined_a = r.refine(&a).unwrap(); + // The canvas shows A; B is asked for, then A' while B renders. The thread finishes B, then + // previews A' before the canvas polls: the queue holds [refined B, preview A'], and a poll + // takes the last. + let queue = [r.refine(&b).unwrap(), r.preview(&a2).expect("A' is a new zoom for the thread")]; + let polled = queue.last().unwrap(); + assert!(polled.coarse && covers(&polled.view, &a2.view)); + let c = CanvasState { + tex_view: Some(refined_a.view.clone()), + tex_of: here, + tex_coarse: false, + want_view: Some(a2.view.clone()), + ..Default::default() + }; + assert!(shows(&c, polled, here), "A at the same zoom shows 1/64 of A': the preview of all of it goes on"); + // Once A' itself is refined on screen, a preview of it would be a step down. + let refined_a2 = r.refine(&a2).unwrap(); + let c = CanvasState { tex_view: Some(refined_a2.view.clone()), ..c }; + assert!(!shows(&c, polled, here)); +} + +#[test] +fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_frame() { + // Big enough that a render from scratch takes well over a frame: blurs and glass on 2400². + let (mut s, rect) = document(); + s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap(); + s.execute("effect.add", &json!({"ids": [rect], "type": "BACKGROUND_BLUR"})).unwrap(); + let ds = s.ds().unwrap(); + let at = |scale| job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), scale, 2400, 2400)); + // The production threshold, as the browser has it. + let mut w = Inline::new(egui::Context::default()); + w.submit(at(2.0)); + let first = w.poll().expect("the first image"); + assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by"); + assert!(first.ms >= 12.0, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms); + // A new zoom: the preview this frame, the refined image the next. + w.submit(at(1.0)); + let p = w.poll().expect("a preview"); + assert!(p.coarse && w.in_flight()); + let d = w.poll().expect("the refined image"); + assert!(!d.coarse && !w.in_flight()); + assert!(same_pixels(&d, &render_now(&at(1.0)).unwrap())); + // A newer request before the refinement: refining the old one is skipped. + w.submit(at(0.5)); + assert!(w.poll().is_some_and(|d| d.coarse)); + w.submit(at(0.25)); + let p = w.poll().expect("the newer one's preview"); + assert!(p.coarse && (p.view.scale - 0.25 / PREVIEW_FACTOR).abs() < 1e-12); + assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 0.25)); + assert!(w.poll().is_none() && !w.in_flight()); +} diff --git a/docs/cli.md b/docs/cli.md index ab6c1ea81..80ccfa596 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -15,6 +15,10 @@ Every subcommand takes `--no-system-fonts` (or set `FIGCRAFT_NO_SYSTEM_FONTS=1`, app honours too) to use only the built-in fonts, for renders that come out the same on every machine, e.g. in CI. +On Linux and other Unix systems except macOS, user fonts are read from +`$XDG_DATA_HOME/fonts`, or `$HOME/.local/share/fonts` when `XDG_DATA_HOME` is unset, +empty or relative. The legacy `$HOME/.fonts` directory is also scanned. + The collaboration server is a binary of its own, `figcraft-server` (`cargo build --release -p figcraft-server`): it keeps files with share links and serves their live sessions, and its `key`, `files`, `links`, `reports`, `block-ip` and `check` subcommands manage them (a server can diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 7888241d6..38ef96ec1 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -386,6 +386,14 @@ resuming with its offline edits, and leaving the session going back to snapshot outlines in the person's colour, and avatars (initials) at the top of the right panel beside Share; click an avatar to **follow** that person's viewport (`collab.follow {client}`), click again, press Escape or pan/zoom yourself to stop. The chevron lists everyone. +- **Removing someone.** Right-click their avatar ▸ "Remove from session" + (`collab.removePerson {client}`), when you host the session or own the file on a server. The + hub closes their connection with "You were removed from this session" (close code 1001: the + session is over for them, no reconnecting) and bars their address (its `ip_key`) from that file + while it runs, so the invite they hold no longer gets them in; everyone else stays connected. On + a server the owner asks on an open hosting connection with the file's owner token + (`Admin::RemovePeer {file, client}`; the operator may too, with the server key). Addresses are + the only identity a session has: someone behind the same address (one NAT) is barred too. - **Text carets.** While someone types in a text layer of the shared document on your page, their caret and selected characters show on it in their colour (the text layout's own caret and selection geometry, so they sit at the right glyphs of your copy), with their name in a tag above @@ -463,7 +471,7 @@ resuming with its offline edits, and leaving the session going back to snapshot `collab.share {via?: "online" | "local", access?, …}`, `collab.host {port?, bind?: "local" | "lan" | }`, `collab.stop`, `collab.join {code | link}`, `collab.leave`, `collab.peers`, `collab.follow {client?}`, `collab.cursor {x?, y?}` (an agent's - pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`. + pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`, `collab.removePerson {client}`. - **Sessions are per document.** Every open file has its own session or none: hosting, joined, being published, reconnecting or failed. Several run at once (host one file, join another, publish a third), each with its own people, links, undo history, cursor chat, spotlight and @@ -726,8 +734,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/collaboration-parity.md b/docs/collaboration-parity.md index e7eb468f2..95084d740 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -15,7 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | -| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The list is per computer: no accounts, teams or projects | 8–17 | +| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | | Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 | @@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged | | 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | | 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. Files are known per computer, not per person (no accounts): percentages and hours unchanged | | 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Usability and hosting rather than a new capability: percentages and hours unchanged | diff --git a/docs/components-parity.md b/docs/components-parity.md index 46a012fe3..659676fd4 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -69,13 +69,14 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Explicit modes per frame, library collections' modes | done | | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | -| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export | +| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) | | Expressions in prototype set-variable actions | done | see [prototyping-parity](prototyping-parity.md) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. | | 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | | 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | | 2026-10-12 | minor | Layers and Assets panels behaviour suite: Assets folders by page, frame and slash names; a leading `.` or `_` keeps a component out of the library even after it was published, and removing it brings the component back | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9fc9937d9..f751c95b7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -35,6 +35,13 @@ instead of dispatching the new request. These limits apply to the opt-in loopbac requests still queued after the UI timeout are discarded, including requests deferred to a hidden window. A command already running is not interrupted. +A minimised or covered window (the main one or another, see Windows below) draws no frames, but +still answers `engine.execute`, `engine.commands`, `document.inspect`, `node.get`, `window.list` +and `ui.render`, acting on the file that window shows, so an attached agent can edit, inspect and +save while the app is out of sight. Screenshots, input (`ui.input`, +`ui.pointer`, `ui.key`, `ui.type`) and the interface's own state wait for the window to show, and +time out after 60 s if it doesn't. + Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are relative to the parent frame (groups have no coordinate space of their own). @@ -51,7 +58,9 @@ unknown id is an error. Run any command (the same ids as the menus, shortcuts, Actions palette, CLI and MCP). Programmatic calls never open dialogs: dialog requests are dropped. Returns the command's result (creation -commands return `{"id": N}`). +commands return `{"id": N}`). Paints (`fills`, `strokes`, `paint`) take a colour, the full paint +JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"],"from"?,"to"?}` +(as in `docs/mcp.md`). ```json {"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}} @@ -60,6 +69,15 @@ commands return `{"id": N}`). {"id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}} ``` +Inside `command.batch`, `{"$step": n}` in a step's params stands for the `id` in step `n`'s result +(zero-based; `{"$step": n, "field": "…"}` for another field; any object with a `$step` key is read +as one, and a nested `command.batch` step keeps its own references to its own steps), so one batch +can create a frame and fill it: + +```json +{"id":4,"method":"engine.execute","params":{"command":"command.batch","params":{"commands":[{"command":"node.create","params":{"type":"frame","width":200,"height":100}},{"command":"node.create","params":{"type":"rectangle","parent":{"$step":0},"width":20,"height":20}}]}}} +``` + ### `engine.commands {}` Every command: `[{id, label, params, menu, shortcut, undoable}]`. @@ -93,7 +111,12 @@ ellipse, its arc handles `arcHandles`, for a selected rectangle, frame, polygon an idle window draws none) with the most frequent repaint requests behind them (`repaintCauses`: `[{at: "file:line", count}]`, every request made in the frame before each one, delayed ones such as the autosave deadline included; egui's `context.rs` begin-pass line means input events) and what the last canvas render had to do (`canvas.renderWork`: `full`, `partial` with -the `pixels` re-rendered after a pan or an edit, or `reused`). While a prototype plays, `presenter` +the `pixels` re-rendered after a pan or an edit, or `reused`), whether the canvas shows a coarse +zoom preview or the refined image (`canvas.quality`: `preview` or `refined`) and the time from +asking for a render to its preview and its refined image being handed to egui as the canvas +texture (`canvas.previewLatencyMs`, `canvas.refineLatencyMs`, on a monotonic clock, rendering +included in the browser too; the GPU upload and presenting the frame come after; `null` until +there was one). While a prototype plays, `presenter` (full window) and `preview` (the preview pane) hold the player's state: the `current` frame and its `name`, `start`, `history`, open `overlays`, how many delayed mouse triggers are `waiting`, the screen's `scroll` offset and `scrollRange`, the `device` (and whether it is turned to `landscape`, its `deviceColor`) and the `viewport` of the frame diff --git a/docs/gaps.md b/docs/gaps.md index 34c4f4390..07192dfd1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (`.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | -| 5 | Localization: English plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts | Localization | 29–51 | [localization-parity](localization-parity.md) | +| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts | Localization | 29–51 | [localization-parity](localization-parity.md) | | 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | @@ -142,7 +142,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). -### 5. Localization: English plus unreviewed drafts +### 5. Localization: English, reviewed Russian, plus unreviewed drafts - **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts and the Latin American Spanish overlay, the 25 newest strings in five drafts, Hindi, Arabic, Indonesian and Vietnamese, localized engine messages and number @@ -152,8 +152,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe Preferences ▸ Language, `pref.language`; `en.toml` is complete, and `es.toml`, `fr.toml`, `de.toml`, `pt-BR.toml`, `ja.toml`, `ko.toml` and `zh-Hans.toml` (2026-10-12) are machine drafts (all but `zh-Hans.toml` lack the 25 newest strings), with `es-419.toml` a Latin American overlay - listing only what differs from Spanish (only Spanish is held to completeness by the check; the - others show English for strings added later). + listing only what differs from Spanish (Spanish and Russian are held to completeness by the check; the + others show English for strings added later). Russian adds all 2,165 current entries, + reviewed and approved by VanTan, with one/few/many integer plurals. CJK UI text uses an installed CJK face the desktop app loads when something on screen needs it (`crates/ui-egui/src/cjk.rs`). Engine error messages and Dev Mode values stay English. - **Impact:** non-English designers get machine drafts nobody has reviewed yet, and English @@ -193,7 +194,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ### 7. CPU canvas where Figma uses the GPU - **Missing:** GPU rendering of the canvas. FigCraft renders with tiny-skia on the CPU (banded, - parallel, retained) and shows the image through wgpu. + parallel, retained) and shows the image through wgpu. A new zoom can't reuse the retained + image; when renders from scratch take a frame or more, the whole view is previewed with a + sixteenth of the pixels first, then refined. - **Evidence (measured, `cargo xtask bench`):** a 50k-layer page ~8 ms zoomed out with levels of detail, single-layer edits at 10k layers ~0.2 ms; full 1440×900 renders 7–15 ms at 10k layers. Heavy effects (glass, background blur, noise) and 4K/5K canvases at 2x are where CPU time grows. @@ -394,6 +397,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | | 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged | | 2026-10-12 | minor | Gap 2: sharing with a join code (Share ▸ Online or Local network, one Start sharing button, a short code to give others, one join field for a code or a link; the server names links with codes and limits wrong ones) added to the landed list; usability, so the estimate and ranking are unchanged | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 8995db252..a990d75b6 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 99f0f1a70..ecf1bc71a 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 2b66657c2..6e69384c7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -128,10 +128,10 @@ Or in a project's `.mcp.json`: resolved `gridCell`. - **Every menu action is a command** (`list_commands`), and most commands act on `ids` when given, else on the selection. New layers become the selection. -- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills, strokes and effects passed through the MCP - tools, `color` may also be a hex string and gradients can be written - `{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; the server converts them to the - engine's paint JSON. +- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills and strokes, `color` may also be a hex + string and gradients can be written `{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; + the engine reads them as its paint JSON, so `run_command`, `figcraft-cli run` and the control + channel take them too. Effects passed through the MCP tools may use hex `color` strings as well. - **Blend modes**: `blend` on a layer (`node.set`) or a paint (`fill.set` / `stroke.set`) is a menu label or CSS keyword (`"Normal"`, `"pass through"`, `"multiply"`, `"plusDarker"`, ...; `normal` is Normal). Groups and frames start as pass-through: their children blend with what is @@ -219,7 +219,7 @@ Or in a project's `.mcp.json`: |---|---| | `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. | | `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React and Leptos, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). | -| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. | +| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: `{"$step": n}` anywhere in its params becomes the `id` in step n's result (zero-based), and `{"$step": n, "field": "…"}` another field of it (see Step references below). | | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. | | `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). | @@ -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 @@ -349,7 +349,9 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. -- `pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": +- `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change + (off by default; the desktop app's timer does it, so scripts still call `file.save`). +- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English. @@ -1090,6 +1092,11 @@ only: a host stops hosting it, a guest leaves it. pointer. Your selection, page and view go out by themselves when they change. - `collab.follow {client}` follows that person's viewport; `collab.follow {}` stops (so does any pan or zoom of your own, or Escape). +- `collab.removePerson {client}` removes that person from the session: they are told the session + is over for them (they keep their copy and don't reconnect), and their address can't join the + file again while the session runs. Only the person hosting, or the owner of a + file on a server (with its owner token), can; a server older than this request says it can't. + Returns `{removed, name}`. - `collab.cursorChat {text}` shows `text` next to your cursor for everyone in the session (put the cursor somewhere first with `collab.cursor`): at most 100 characters, control characters become spaces, `""` closes it. Others see it fade 5 seconds after its last change, so send it again to @@ -1406,6 +1413,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, @@ -1509,6 +1522,29 @@ Align and distribute as one undo step: {"command":"distribute.horizontal","params":{"ids":[3,4,5]}}]}}} ``` -The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of ten realistic -tasks (auto layout, components and overrides, gradients and shadows, paint styles, Light/Dark -variables, align/distribute/group, boolean subtract + flatten, export, undo/redo, drawing with tools). +### Step references + +A step in a `batch` can use what an earlier step returned. `{"$step": n}` anywhere in a step's +params (a value, an item in `ids`, a nested field) is replaced by the `id` in the result of step `n` +(zero-based) before the step runs; `{"$step": n, "field": "name"}` takes another field of that +result. This builds a nested tree in one call and one undo step, without guessing ids: + +```json +{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"batch","arguments":{"label":"Card","commands":[ + {"command":"node.create","params":{"type":"frame","name":"Card","props":{"layout":"vertical","gap":12}}}, + {"command":"node.create","params":{"type":"text","parent":{"$step":0},"text":"Ada Lovelace"}}, + {"command":"node.create","params":{"type":"frame","parent":{"$step":0},"name":"Button"}}, + {"command":"node.create","params":{"type":"text","parent":{"$step":2},"text":"Follow"}}]}}} +``` + +A reference to the same or a later step, a negative or non-integer index, a missing `field` or any +other key next to `$step` fails the whole batch, which is rolled back. Only an object whose keys are +`$step` (and optionally `field`) is a reference: text such as `"$step 0"` is left as written. The +`$step` key is reserved in step params: an object holding it anywhere (in plugin data or a variable +value too) is read as a reference. A step that is itself a `command.batch` is passed on as written, +so the references in its steps are to its own steps, not the outer batch's. + +The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of realistic tasks +(auto layout, a card built in one batch with step references, components and overrides, gradients +and shadows, paint styles, Light/Dark variables, align/distribute/group, boolean subtract + flatten, +export, undo/redo, drawing with tools). diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e2780d68..12f2a5fbf 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 88d999281..f2e4820ca 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/target-app-parity.md b/docs/target-app-parity.md index c34d59169..e15ddaed6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -129,7 +129,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | | Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) | | Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | -| Localization | 3 | 39% | 29–51 | The layer, an English catalog and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese (none reviewed by a native speaker; every draft covers all current strings), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | +| Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese (none reviewed by a native speaker; every draft covers all current strings), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | @@ -628,6 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | | 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | | 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | diff --git a/docs/translating.md b/docs/translating.md index b7c26a401..6a925d811 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -4,17 +4,22 @@ 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. `pref.language {language}` does the same from the CLI, MCP and the control channel. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay -`es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`) -and Simplified Chinese (`zh-Hans`), machine-drafted and waiting for native speakers' review. +`es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`), +Simplified Chinese (`zh-Hans`), Czech (`cs`) and Indonesian (`id`), machine-drafted and waiting for +native speakers' review. Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog +keeps Frame, Dev Mode and Smart animate as familiar tool names and uses « » quotes. Turkish (`tr`) +was machine-drafted and reviewed by a native speaker. ## The catalogs @@ -43,7 +48,15 @@ Enter for the key, control for a gamepad, resorte) and keeps Spanish's tú. Japa concise, without です/ます endings, with full-width ()「」:、。 where Japanese UI text has them; Korean labels end without 합니다 (instructions in -세요). Simplified Chinese is concise, with full-width ():,、。“” in Chinese text and a space between Chinese and Latin words or numbers. -None of the three has plural forms. +None of the three has plural forms. Turkish uses bare imperatives for buttons and menu items +(Kaydet, Sil) and the polite -in/-ın for instructions (seçin, tıklayın), puts the percent sign before +the number (%50) and keeps a noun singular after a count, so its plural entries give only `other`. +Czech uses formal address, „ “ quotes and no space before `:`. Use consistent design terms (rámec, +komponenta, instance, varianta, automatické rozvržení, omezení, výplň, tah, efekt, prototyp, +proměnná, styl). Indonesian uses bare imperatives for buttons and menu items (Simpan, Hapus), the +formal Anda (never kamu), keeps established design terms in English (frame, layer, component, +instance, auto layout, plugin, file) and keeps a noun unchanged after a count, so its plural entries +give only `other`. CJK text needs a CJK font, which egui and Inter lack: the desktop app adds an installed one when something on screen needs it (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts, @@ -59,14 +72,20 @@ language also needs its script in `cjk::language`. 2. Translate every value; keep the keys. Keep `{placeholders}` exactly, "…" where English has it, brand names and keyboard glyphs. English labels of commands: `figcraft-cli commands`. 3. Plurals: give `one` only if the language has a singular form (Japanese, Korean and Chinese give - only `other`, and `plural_one` returns false for them). `trn` picks `one` for a count of 1, and - also for 0 in French and Brazilian Portuguese (keep `{n}` in their `one` forms); a language with other rules needs its rule in - `plural_one` in [`crates/ui-egui/src/i18n.rs`](../crates/ui-egui/src/i18n.rs). + only `other`). `other` is required; `one`, `few` and `many` are optional. `trn` picks `one` for + 1 (also 0 in French and Brazilian Portuguese). Russian uses `one` for 1, 21, 31… except 11; + `few` for 2–4, 22–24… except 12–14; and `many` for the other integers, including 0. Keep `{n}` + in all Russian forms. Add new rules to `plural` in + [`crates/ui-egui/src/i18n.rs`](../crates/ui-egui/src/i18n.rs). A missing form reads `other`; + a missing key uses the fallback catalog's own plural rule. Czech has no rule of its own yet, so it + picks `one` for 1 and `other` for everything else: its `other` forms are count-neutral for 0, + 2–4 and 5+ (e.g. `Vrstvy: {n}`). 4. To require full coverage, add the code to `COMPLETE` in [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that adds a string then has to translate it into each complete language, so only Spanish is there: - French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese are registered as - drafts, and a string added later shows English in them until it is translated. To list what a draft lacks, + French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian + are registered as drafts, Russian and Turkish are reviewed but not required, and a string added + later shows English in them until it is translated. To list what a draft lacks, add its code to `COMPLETE` locally and run the check. 5. Run the check and look at the panels in the app (longer words must fit the right panel): @@ -86,7 +105,7 @@ Wrap every string a user sees or a screen reader hears: `tr` for plain text, `tr placeholders, `trn` for counts, `tr_noop` for literals in `const` tables that are looked up later with `tr(label)`. The key must be a string literal as the first argument (the check reads the source). Don't wrap ids, command ids, JSON, log messages, file extensions or document content. Then -add the key to `en.toml` and to each complete language (Spanish); the drafts may follow later. +add the key to `en.toml` and to each complete language (Spanish and Russian); the drafts may follow later. Labels from the engine and document crates (tool names, blend modes, easing curves…) are looked up at display with `tr(x.label())`; they are listed in `dynamic_labels` in `i18n_tests.rs`. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6de538e7a..0cdc94e39 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 | diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 693167656..bb9a77994 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -76,8 +76,11 @@ for b in "${BINS[@]}"; do install -m 755 "$BIN/$b" "$STAGE/bin/" strip "$STAGE/bin/$b" 2>/dev/null || true done -# The desktop entry, MIME type, metainfo and icons are the freedesktop files Linux ships. -install -m 644 "$LINUX/$APP_ID.desktop" "$STAGE/share/applications/$APP_ID.desktop" +# The desktop entry, MIME type, metainfo and icons are the freedesktop files Linux ships. The +# window's X11 class is the binary's name here (egui-winit names the window after the app id on +# Linux only), so the entry matches that. +sed -e "s/^StartupWMClass=.*/StartupWMClass=figcraft/" "$LINUX/$APP_ID.desktop" >"$STAGE/share/applications/$APP_ID.desktop" +chmod 644 "$STAGE/share/applications/$APP_ID.desktop" install -m 644 "$LINUX/$APP_ID.mime.xml" "$STAGE/share/mime/packages/$APP_ID.xml" sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$FIGCRAFT_BUILD_DATE/g" \ "$LINUX/$APP_ID.metainfo.xml.in" >"$STAGE/share/metainfo/$APP_ID.metainfo.xml" diff --git a/packaging/linux/ai.storyteller.figcraft.desktop b/packaging/linux/ai.storyteller.figcraft.desktop index 767dd6e54..9c0adcf9f 100644 --- a/packaging/linux/ai.storyteller.figcraft.desktop +++ b/packaging/linux/ai.storyteller.figcraft.desktop @@ -8,7 +8,7 @@ TryExec=figcraft Icon=ai.storyteller.figcraft Terminal=false StartupNotify=true -StartupWMClass=figcraft +StartupWMClass=ai.storyteller.figcraft Categories=Graphics;2DGraphics;VectorGraphics;X-Design; Keywords=design;interface;ui;ux;prototype;wireframe;mockup;vector;layout;components; MimeType=application/x-figcraft;image/svg+xml;image/png;image/jpeg;x-scheme-handler/figcraft; diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index 69667a2b5..0adbfb04e 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -51,7 +51,11 @@ overrides: rpm: # Soname provides rather than package names, so the same requirements resolve on Fedora/RHEL # (libxkbcommon-x11, libX11-xcb, ...) and openSUSE (libxkbcommon-x11-0, libX11-xcb1, ...). + # The glibc floor matches the deb's libc6 (>= 2.35). It names libm because the binaries' + # GLIBC_2.35 symbols (hypot, hypotf) are libm's: RHEL 9's glibc 2.34 provides + # libc.so.6(GLIBC_2.35) through backports, but not this, so it refuses the install. depends: + - libm.so.6(GLIBC_2.35)(64bit) - libxkbcommon.so.0()(64bit) - libxkbcommon-x11.so.0()(64bit) - libwayland-client.so.0()(64bit)