diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index 267479eb..e4cd6a93 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -27,7 +27,7 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
- uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
+ uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
with:
app-name: figcraft
app-dir: FigCraft
diff --git a/Cargo.lock b/Cargo.lock
index ad774f84..2f39fc5f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1041,6 +1041,17 @@ dependencies = [
"objc2-app-kit 0.3.2",
]
+[[package]]
+name = "craft-settings"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "craft-atomic",
+ "dirs",
+ "serde",
+ "toml_edit",
+]
+
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1266,6 +1277,27 @@ dependencies = [
"crypto-common 0.2.2",
]
+[[package]]
+name = "dirs"
+version = "7.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -1675,6 +1707,7 @@ version = "0.12.1"
dependencies = [
"arboard",
"craft-menu",
+ "craft-settings",
"craft-update",
"craft-window",
"eframe",
@@ -1904,6 +1937,7 @@ dependencies = [
"crc32fast",
"figcraft-color",
"figcraft-doc",
+ "figcraft-format",
"figcraft-geom",
"figcraft-pathops",
"figcraft-text",
@@ -1964,6 +1998,7 @@ dependencies = [
"figcraft-doc",
"figcraft-geom",
"harfrust 0.13.3",
+ "icu_segmenter",
"log",
"objc2 0.6.5",
"png",
@@ -2023,6 +2058,7 @@ dependencies = [
"egui",
"figcraft-collab-net",
"figcraft-engine",
+ "figcraft-text",
"figcraft-ui-egui",
"js-sys",
"log",
@@ -2742,6 +2778,29 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "icu_segmenter"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
+dependencies = [
+ "core_maths",
+ "icu_collections",
+ "icu_locale_fallback",
+ "icu_provider",
+ "icu_segmenter_data",
+ "potential_utf",
+ "smallvec",
+ "utf8_iter",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_segmenter_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
+
[[package]]
name = "idna"
version = "1.1.0"
@@ -3685,6 +3744,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -4266,6 +4331,16 @@ dependencies = [
"bitflags 2.13.2",
]
+[[package]]
+name = "redox_users"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
+dependencies = [
+ "libredox",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "regex"
version = "1.13.1"
@@ -5228,8 +5303,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
dependencies = [
"indexmap",
+ "serde_core",
+ "serde_spanned",
"toml_datetime",
"toml_parser",
+ "toml_writer",
"winnow",
]
diff --git a/Cargo.toml b/Cargo.toml
index 9177f208..6a951ff3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -51,6 +51,8 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff13
# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button
# (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work).
craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+# Where the settings live: the suite's folder rules, portable mode included (craft-libs).
+craft-settings = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -78,6 +80,11 @@ skrifa = "0.47"
harfrust = "0.13"
unicode-bidi = "0.3"
unicode-segmentation = "1.13"
+# Line breaks between the words of Thai, Lao, Khmer and Burmese, which are written without spaces
+# (#1219): ICU4X's line segmenter with its compiled data (pure Rust, Unicode-3.0 like the icu_*
+# crates already in the lock, whose version it matches). figcraft-text picks the dictionaries, or
+# in the browser the smaller LSTM models.
+icu_segmenter = { version = "2.3", default-features = false, features = ["compiled_data"] }
# Code export: layer names transliterated into ASCII identifiers (pure Rust data tables, zero
# dependencies, BSD-3-Clause, no_std so the web build keeps it too).
deunicode = "1.6"
diff --git a/ROADMAP.md b/ROADMAP.md
index 59be86c5..491b9c35 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -59,7 +59,7 @@ native speakers' reviews of the nine language drafts need people.
| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) |
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
-| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
+| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins and publishes to a server, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
@@ -355,6 +355,37 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged |
+| 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged |
+| 2026-10-30 | minor | M9, #917: the browser build publishes to a `figcraft-server`. Share ▸ Online (the server in Preferences) and Share ▸ Publish to server… upload the open file over the page's own WebSocket, make the links and join codes others join with, and keep the tab in sync with the new file, as the desktop app does; asking a server about its hosting, stopping a share and sharing again go out the same way. The Share popover offers only online sharing there (a page can't listen, so the local network stays the desktop app's); without a server in Preferences it says so and offers joining. A failed publish says why and leaves the file as it was. The browser keeps only your name across reloads, so the owner token of a file it put on an open server lasts as long as the page (`crates/collab-net/src/exchange.rs`, run by the browser client's driver; the requests and what their answers mean are shared with the desktop transport in `requests.rs`). Tests: `exchange_tests` (scripted server: with and without the key, sharing or not, refusals, an old server without join codes, time-outs, leaving while publishing, the background requests), `live_join_tests`; checked in a browser against a local server. Percentages and hours unchanged |
+| 2026-10-30 | minor | M4, #1051: fonts installed on this computer in the browser build. Where the browser lists them (Chromium's Local Font Access, `window.queryLocalFonts`), the Design panel's font menu offers "Use fonts installed on this computer…"; the browser asks for permission, and the fonts join the font list by name, with their styles. A font's bytes are read only when text uses it, one style at a time (its preview in the menu waits until then), and auto-sizing text fits it once it is in; one that can't be read is dropped and its text keeps the fallback. A family the build embeds keeps its faces; the computer's styles it lacks join it. Other browsers show no row (`figcraft_text::add_local_fonts`, `apps/figcraft-web/src/local_fonts.rs`). Tests: `fontdb::local_tests`, `local_fonts_tests`; checked in a browser. Percentages and hours unchanged |
+| 2026-10-30 | minor | M8, #1550: SVG import reads CSS colour functions its SVG parser doesn't (it painted them black, or left strokes out, without a word): `color(srgb …)` and `color(display-p3 …)`, with alpha, converted to the document's profile, and `color-mix(in srgb|oklab|oklch, …)`, evaluated at import (percentages, alpha and nested mixes as CSS Color 5 has them), in colour attributes, `style` attributes and `
+
+
+ "#;
+ let (nodes, notes) = import_in(ColorProfile::Srgb, body);
+ assert!(notes.iter().all(|n| !n.contains("the colour `")), "{notes:?}");
+ let rect = nodes.get(1).unwrap();
+ assert_eq!(rgba8(rect.fills.first().and_then(Paint::solid_color)), Some([255, 0, 0, 255]));
+ assert_eq!(rgba8(rect.strokes.first().and_then(Paint::solid_color)), Some([128, 0, 128, 255]));
+ let stops = match nodes.get(2).and_then(|n| n.fills.first()).map(|p| &p.kind) {
+ Some(figcraft_color::PaintKind::Gradient(g)) => g.stops.clone(),
+ k => panic!("{k:?}"),
+ };
+ assert_eq!(stops.first().map(|s| s.color.to_rgba8()), Some([0, 255, 0, 255]));
+ // Text and other attributes keep their words.
+ let svg = r#""#;
+ let (out, _) = resolve(svg.as_bytes(), ColorProfile::Srgb).unwrap();
+ assert_eq!(
+ String::from_utf8(out).unwrap(),
+ r##""##
+ );
+}
+
+#[test]
+fn what_isnt_read_gets_a_note_and_a_grey_not_black() {
+ let grey = Some([128, 128, 128, 255]);
+ for call in [
+ "color(srgb a b c)",
+ "color(srgb 1 0)",
+ "color(from red srgb r g b)",
+ "color-mix(in srgb, red)",
+ "color-mix(srgb, red, blue)",
+ "color-mix(in srgb, red 150%, blue)",
+ "color-mix(in srgb, red 0%, blue 0%)",
+ "color-mix(in srgb, currentcolor, blue)",
+ "color(srgb 1 0 0",
+ ] {
+ let (c, notes) = fill(call);
+ assert_eq!(rgba8(c), grey, "{call}");
+ assert!(notes.iter().any(|n| n.contains("the colour `")), "{call}: {notes:?}");
+ }
+ // Spaces and mixes it doesn't know: close, with a note saying how.
+ let (c, notes) = fill("color(rec2020 0 0 1)");
+ assert_eq!(rgba8(c), Some([0, 0, 255, 255]));
+ assert!(notes.iter().any(|n| n.contains("rec2020") && n.contains("sRGB")), "{notes:?}");
+ let (c, notes) = fill("color-mix(in hsl, red, blue)");
+ assert_eq!(rgba8(c), Some([128, 0, 128, 255]));
+ assert!(notes.iter().any(|n| n.contains("mixed in sRGB")), "{notes:?}");
+}
+
+#[test]
+fn garbage_and_deep_nesting_are_bounded() {
+ // Nested deeper than the limit: a note, no stack overflow.
+ let deep = (0..50).fold("red".to_string(), |acc, _| format!("color-mix(in srgb, {acc}, blue)"));
+ let (c, notes) = fill(&deep);
+ assert!(c.is_some() && notes.iter().any(|n| n.contains("nested")), "{notes:?}");
+ // Many calls, unclosed ones, odd bytes: each attribute is read, nothing panics.
+ let many = "color(srgb 0 0 1) ".repeat(5000);
+ assert!(
+ resolve(format!(r#""#).as_bytes(), ColorProfile::Srgb).is_some()
+ );
+ let mut seed = 7u64;
+ let pieces = ["color(", "color-mix(", "in ", "srgb", "oklch", ")", "(", ",", " ", "%", "/", "50", "1e40", "NaN", "é", "#fff", "none"];
+ for _ in 0..500 {
+ let junk: String = (0..12)
+ .map(|_| {
+ seed ^= seed << 13;
+ seed ^= seed >> 7;
+ seed ^= seed << 17;
+ pieces[(seed % pieces.len() as u64) as usize]
+ })
+ .collect();
+ let svg = format!(r#""#);
+ let _ = resolve(svg.as_bytes(), ColorProfile::Srgb);
+ let mut d = Document::new("t");
+ let p = d.first_page().unwrap();
+ let _ = import_svg_with_notes(&mut d, svg.as_bytes(), p, 0.0, 0.0, "x");
+ }
+ // Nothing to do: the document comes back as it was.
+ assert!(resolve(br#""#, ColorProfile::Srgb).is_none());
+ assert!(resolve(b"\xff\xfecolor(", ColorProfile::Srgb).is_none());
+}
diff --git a/crates/svg/src/import/paint.rs b/crates/svg/src/import/paint.rs
index ebee6692..6e6300cc 100644
--- a/crates/svg/src/import/paint.rs
+++ b/crates/svg/src/import/paint.rs
@@ -228,17 +228,36 @@ fn unstale(root: &usvg::Group) -> (&usvg::Group, Affine) {
(g, base)
}
+/// A pattern tile drawn into an image: the image's hash, its size and its pixels → pattern space
+/// transform.
+pub(crate) type Tile = (String, f64, f64, Affine);
+
/// Draws pattern `pat`'s tile into an image (at twice its size on the page, at most [`MAX_TILE`]
-/// a side): the image's hash, its size and its pixels → pattern space transform.
-fn render_tile(cx: &mut Ctx, pat: &usvg::Pattern, tile: Affine) -> Option<(String, f64, f64, Affine)> {
+/// a side), once per pattern and scale in an import: other uses get the same tile.
+fn render_tile(cx: &mut Ctx, pat: &usvg::Pattern, tile: Affine) -> Option {
if cx.pattern_depth >= 2 {
return None;
}
let r = pat.rect();
let (rw, rh) = (f64::from(r.width()), f64::from(r.height()));
let k = (length_scale(tile) * 2.0).max(0.01).min(MAX_TILE / rw.max(rh));
+ // The tile depends only on the pattern and the scale; the tree outlives the import, so its
+ // address names the pattern (ids repeat across embedded documents).
+ let key = (std::ptr::from_ref(pat), k.to_bits());
+ if let Some(t) = cx.tiles.get(&key) {
+ return t.clone();
+ }
+ let t = draw_tile(cx, pat, k);
+ cx.tiles.insert(key, t.clone());
+ t
+}
+
+/// [`render_tile`]'s drawing at scale `k`, its layers counted in the import's layer budget.
+fn draw_tile(cx: &mut Ctx, pat: &usvg::Pattern, k: f64) -> Option {
+ let r = pat.rect();
+ let (rw, rh) = (f64::from(r.width()), f64::from(r.height()));
let (pw, ph) = ((rw * k).ceil().max(1.0), (rh * k).ceil().max(1.0));
- if !(pw.is_finite() && ph.is_finite()) || pw > MAX_TILE || ph > MAX_TILE {
+ if !(pw.is_finite() && ph.is_finite()) || pw > MAX_TILE || ph > MAX_TILE || cx.full() {
return None;
}
let mut sub = Document::new("Pattern");
@@ -247,13 +266,15 @@ fn render_tile(cx: &mut Ctx, pat: &usvg::Pattern, tile: Affine) -> Option<(Strin
if let Some(f) = sub.node_mut(frame) {
f.fills.clear();
}
- let notes = {
+ let (notes, count) = {
let mut scx = Ctx::new(&mut sub);
+ scx.count = cx.count;
scx.pattern_depth = cx.pattern_depth + 1;
let (content, base) = unstale(pat.root());
children(&mut scx, content, frame, base, 0);
- std::mem::take(&mut scx.notes)
+ (std::mem::take(&mut scx.notes), scx.count)
};
+ cx.count = count;
cx.notes.extend(notes);
let view = figcraft_render::View { origin: (0.0, 0.0), scale: k, width: pw as u32, height: ph as u32 };
let mut pix = figcraft_render::Pixmap::new(pw as u32, ph as u32)?;
diff --git a/crates/svg/src/import/tile_tests.rs b/crates/svg/src/import/tile_tests.rs
new file mode 100644
index 00000000..42d45cf7
--- /dev/null
+++ b/crates/svg/src/import/tile_tests.rs
@@ -0,0 +1,83 @@
+//! Pattern tiles: drawn once per pattern and scale in an import, their layers counted in the
+//! import's layer budget.
+
+use figcraft_color::{Paint, PaintKind};
+use figcraft_doc::{Document, NodeKind};
+use figcraft_geom::Affine;
+
+use super::{Ctx, MAX_NODES, children, import_svg_with_notes};
+
+fn svg(defs: &str, body: &str) -> String {
+ format!(r##""##)
+}
+
+/// What importing `data` with `spent` layers of the budget already used leaves behind: the tiles
+/// drawn, the layers counted and the notes.
+fn walk(data: &str, spent: usize) -> (usize, usize, Vec) {
+ let tree = usvg::Tree::from_data(data.as_bytes(), &usvg::Options::default()).unwrap();
+ let mut d = Document::new("tiles");
+ let page = d.first_page().unwrap();
+ let frame = d.create(page, NodeKind::Frame, "f", 0.0, 0.0, 100.0, 100.0).unwrap();
+ let mut cx = Ctx::new(&mut d);
+ cx.count = spent;
+ children(&mut cx, tree.root(), frame, Affine::IDENTITY, 0);
+ (cx.tiles.len(), cx.count, cx.notes.into_iter().collect())
+}
+
+const DOTS: &str = r##""##;
+
+#[test]
+fn a_pattern_used_a_thousand_times_is_drawn_once() {
+ let uses: String = (0..1000).map(|i| format!(r#""#, i % 10 * 10, i / 10)).collect();
+ let (tiles, count, _) = walk(&svg(DOTS, &uses), 0);
+ assert_eq!(tiles, 1);
+ // A thousand rectangles and the tile's three layers, once.
+ assert_eq!(count, 1003);
+ // Another scale is another tile.
+ let scaled = format!(r#"{uses}"#);
+ assert_eq!(walk(&svg(DOTS, &scaled), 0).0, 2);
+}
+
+/// The image fills of `data`'s layers, and the images they show.
+fn fills(data: &str) -> Vec<(Paint, Vec)> {
+ let mut d = Document::new("fills");
+ let page = d.first_page().unwrap();
+ let f = import_svg_with_notes(&mut d, data.as_bytes(), page, 0.0, 0.0, "x").unwrap().frame;
+ let layers: Vec<_> = d.subtree(f).into_iter().filter_map(|id| d.node(id)).filter_map(|n| n.fills.first().cloned()).collect();
+ layers
+ .into_iter()
+ .map(|p| {
+ let PaintKind::Image(ip) = &p.kind else { panic!("not an image fill: {p:?}") };
+ let bytes = d.images.get(&ip.image).unwrap().bytes.to_vec();
+ (p, bytes)
+ })
+ .collect()
+}
+
+#[test]
+fn a_reused_tile_is_the_tile_a_single_use_draws() {
+ let once = fills(&svg(DOTS, r#""#));
+ let twice = fills(&svg(DOTS, r#""#));
+ assert_eq!(once.len(), 1);
+ assert_eq!(twice.len(), 2);
+ assert!(twice.iter().all(|f| *f == once[0]), "both uses show the single use's tile");
+}
+
+#[test]
+fn a_nested_pattern_bomb_is_refused_within_the_budget() {
+ // Pattern `a` holds 50 layers, each filled with pattern `b` at its own scale (a tile each),
+ // and `b` holds 50 layers: 2,601 layers from one rectangle (each of `a`'s layers comes with
+ // the group carrying its transform).
+ let b: String = (0..50).map(|_| r#""#).collect();
+ let a: String = (1..=50).map(|i| format!(r#""#)).collect();
+ let defs = format!(
+ r#"{b}{a}"#
+ );
+ let data = svg(&defs, r#""#);
+ // Every tile's layers count.
+ assert_eq!(walk(&data, 0).1, 2601);
+ // With 500 layers left the tiles stop there.
+ let (_, count, notes) = walk(&data, MAX_NODES - 500);
+ assert!(count <= MAX_NODES, "{count}");
+ assert!(notes.iter().any(|n| n.contains("the rest was left out")), "{notes:?}");
+}
diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml
index 9392505d..aea3d3be 100644
--- a/crates/text/Cargo.toml
+++ b/crates/text/Cargo.toml
@@ -17,6 +17,7 @@ harfrust = { workspace = true }
png = { workspace = true }
unicode-bidi = { workspace = true }
unicode-segmentation = { workspace = true }
+icu_segmenter = { workspace = true }
log = { workspace = true }
# Fonts that font managers (RightFont, FontBase, Typeface) activate without copying them into a font
@@ -29,6 +30,12 @@ log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-font-catalog = { workspace = true }
+# The browser build finds Thai, Lao, Khmer and Burmese words with ICU4X's LSTM models (~0.3 MB of
+# wasm) instead of its dictionaries (~1.8 MB, which would take the .wasm past the 24 MiB gate in
+# packaging/web/package.sh).
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+icu_segmenter = { workspace = true, features = ["lstm"] }
+
[target.'cfg(target_os = "macos")'.dependencies]
core-text = { version = "=22.0.0", default-features = false, features = ["link"] }
objc2 = "0.6"
diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs
index c2a935c8..fe63e386 100644
--- a/crates/text/src/behaviour_tests.rs
+++ b/crates/text/src/behaviour_tests.rs
@@ -384,6 +384,41 @@ fn thai_emergency_breaks_keep_marks_and_leading_vowels_with_their_letter() {
assert!(breaks > 0, "the sentence wraps at least once across these widths");
}
+/// Wrapped Thai lines start words at every width that fits the widest word (#1219).
+#[test]
+fn thai_lines_break_between_words() {
+ // #1219: Thai writes no spaces, so lines break where a word starts (ICU4X's word data), never
+ // inside a word that fits on a line: at every width from the widest word's to the sentence's,
+ // each wrapped line starts a word. Font-free: without a Thai face, missing glyphs measure too.
+ let words = ["ระบบ", "ช่วย", "ให้", "ทีม", "พัฒนา", "ซอฟต์แวร์", "จัด", "ทำ", "เอกสาร", "ที่", "มี", "โครงสร้าง", "ได้", "ใน", "ที่", "เดียว"];
+ if face().is_none() {
+ return;
+ }
+ let starts: Vec = words
+ .iter()
+ .scan(0, |at, w| {
+ let start = *at;
+ *at += w.chars().count();
+ Some(start)
+ })
+ .collect();
+ let s = words.concat();
+ let width = |s: &str| layout(&text(s, TextAutoResize::WidthAndHeight), None, None).caret(s.chars().count()).0;
+ let widest = words.iter().map(|w| width(w)).fold(0.0, f64::max) + 1.0;
+ let total = width(&s);
+ let t = text(&s, TextAutoResize::Height);
+ let mut wraps = 0;
+ for k in 0..20 {
+ let box_width = widest + (total - widest) * f64::from(k) / 20.0;
+ let l = layout(&t, Some(box_width), None);
+ for line in l.lines.iter().skip(1) {
+ assert!(starts.contains(&line.char_start), "at {box_width:.1} px a line starts inside a word: {:?}", line_texts(&t, &l));
+ wraps += 1;
+ }
+ }
+ assert!(wraps >= 20, "the sentence wraps at every width below its own");
+}
+
#[test]
fn symbols_come_from_a_symbol_font_when_one_is_installed() {
// ⌖ (U+2316) and ⤒ (U+2912) are in no Latin text face. With a symbol font that has them (Apple
diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs
index fbccb970..6d094e9d 100644
--- a/crates/text/src/breaks.rs
+++ b/crates/text/src/breaks.rs
@@ -1,6 +1,9 @@
//! Where lines may break and where carets may stop: break opportunities (a small subset of UAX #14
-//! with the CJK line-start and line-end rules) and grapheme clusters (UAX #29).
+//! with the CJK line-start and line-end rules, plus the word boundaries of Thai, Lao, Khmer and
+//! Burmese from ICU4X's line segmenter) and grapheme clusters (UAX #29).
+use icu_segmenter::options::LineBreakOptions;
+use icu_segmenter::{LineSegmenter, LineSegmenterBorrowed};
use unicode_segmentation::UnicodeSegmentation;
/// The break class of a char.
@@ -76,6 +79,58 @@ pub(crate) fn is_space(c: char) -> bool {
class(c) == Class::Space
}
+/// Whether `c` is in a script written without spaces between words that the line segmenter has
+/// word data for (UAX #14 class SA): Thai, Lao, Burmese (with the Myanmar extensions) and Khmer.
+fn writes_no_spaces(c: char) -> bool {
+ matches!(c, '\u{0E00}'..='\u{0EFF}' | '\u{1000}'..='\u{109F}' | '\u{1780}'..='\u{17FF}' | '\u{19E0}'..='\u{19FF}' | '\u{A9E0}'..='\u{A9FF}' | '\u{AA60}'..='\u{AA7F}')
+}
+
+/// ICU4X's line segmenter with its dictionaries for Thai, Lao, Khmer and Burmese words.
+#[cfg(not(target_arch = "wasm32"))]
+fn segmenter() -> LineSegmenterBorrowed<'static> {
+ LineSegmenter::new_dictionary(LineBreakOptions::default())
+}
+
+/// ICU4X's line segmenter with its LSTM models for Thai, Lao, Khmer and Burmese words: a sixth of
+/// the dictionaries' size, which the browser's download budget can't take (crates/text/Cargo.toml),
+/// though they split a few compounds the dictionaries keep whole.
+#[cfg(target_arch = "wasm32")]
+fn segmenter() -> LineSegmenterBorrowed<'static> {
+ LineSegmenter::new_lstm(LineBreakOptions::default())
+}
+
+/// For each char of `chars`, whether a line may break before it because a Thai, Lao, Khmer or
+/// Burmese word starts there (#1219). These scripts write no spaces between words, so the words come
+/// from the segmenter's word data. Only breaks inside a run of these scripts are marked: where the
+/// run meets other text, [`break_between`] decides. Empty when the text has none of them.
+pub(crate) fn word_breaks(chars: &[char]) -> Vec {
+ if !chars.iter().copied().any(writes_no_spaces) {
+ return Vec::new();
+ }
+ let segmenter = segmenter();
+ let mut breaks = vec![false; chars.len()];
+ let mut at = 0;
+ for run in chars.chunk_by(|a, b| writes_no_spaces(*a) == writes_no_spaces(*b)) {
+ if run.first().copied().is_some_and(writes_no_spaces) {
+ let text: String = run.iter().collect();
+ let offsets: Vec = text.char_indices().map(|(byte, _)| byte).collect();
+ for byte in segmenter.segment_str(&text) {
+ // The run's start (char 0) and its end (no char) are not inside it. Marks with no
+ // letter before them can make ICU4X break before a mark: never split those.
+ if let Ok(k) = offsets.binary_search(&byte)
+ && let (Some(&a), Some(&b)) = (k.checked_sub(1).and_then(|j| run.get(j)), run.get(k))
+ && safe_emergency_break(a, b)
+ && let Some(brk) = breaks.get_mut(at + k)
+ {
+ *brk = true;
+ }
+ }
+ }
+ at += run.len();
+ }
+ breaks
+}
+
/// Whether a line may break between `a` and the `b` after it when the text offers no break
/// opportunity and has to break inside a word (Thai, Lao, Khmer and Burmese write no spaces):
/// never before a letter's trailing mark, which belongs to the letter before it, and never after a
@@ -156,4 +211,66 @@ mod tests {
assert!(safe_emergency_break('\u{0E01}', '\u{0E02}'), "between two letters is fine");
assert!(safe_emergency_break('\u{0E2B}', '\u{0E40}'), "before a leading vowel is fine");
}
+
+ /// The text split where [`word_breaks`] marks a word start.
+ fn split_words(text: &str) -> Vec {
+ let chars: Vec = text.chars().collect();
+ let breaks = word_breaks(&chars);
+ let mut words = vec![String::new()];
+ for (k, c) in chars.iter().enumerate() {
+ if breaks.get(k).copied().unwrap_or(false) {
+ words.push(String::new());
+ }
+ words.last_mut().unwrap().push(*c);
+ }
+ words
+ }
+
+ /// Thai words come from the dictionary, compounds and leading vowels included.
+ #[test]
+ fn thai_lines_break_between_words() {
+ // #1219's sentence ("the system helps software teams produce structured documents in one
+ // place"): ซอฟต์แวร์ (software) and โครงสร้าง (structure) stay whole, and no word is split
+ // after a leading vowel or before a mark. Pinned to ICU4X 2.3's Thai dictionary.
+ let words = ["ระบบ", "ช่วย", "ให้", "ทีม", "พัฒนา", "ซอฟต์แวร์", "จัด", "ทำ", "เอกสาร", "ที่", "มี", "โครงสร้าง", "ได้", "ใน", "ที่", "เดียว"];
+ assert_eq!(split_words(&words.concat()), words);
+ }
+
+ /// Lao, Khmer and Burmese words come from their dictionaries too.
+ #[test]
+ fn lao_khmer_and_burmese_lines_break_between_words() {
+ // "new world, the Lao language"; "Khmer is the official language"; "the Burmese language".
+ for words in [&["ໂລກ", "ໃໝ່", "ພາສາ", "ລາວ"][..], &["ភាសាខ្មែរ", "គឺជា", "ភាសាផ្លូវការ"], &["မြန်မာဘာသာ", "စကား", "သည်"]]
+ {
+ assert_eq!(split_words(&words.concat()), words);
+ }
+ }
+
+ /// Text without these scripts is left to the UAX #14 rules, and so is the edge of a run.
+ #[test]
+ fn word_breaks_leave_other_text_alone() {
+ assert!(word_breaks(&"Latin, 日本語 and Ελληνικά".chars().collect::>()).is_empty(), "no work without these scripts");
+ // Where a Thai run meets Latin or a space, break_between decides: no break inside "ไทยabc".
+ assert_eq!(split_words("ภาษาไทยabcภาษาไทย ไทย"), ["ภาษา", "ไทยabcภาษา", "ไทย ไทย"]);
+ }
+
+ /// Malformed runs neither panic nor start a word on a mark.
+ #[test]
+ fn word_breaks_survive_junk() {
+ // Stray marks, a lone leading vowel, joiners and a long run: no panic, and never a word
+ // start on a mark, which belongs to the letter before it.
+ let long = "ก\u{0E49}".repeat(5_000);
+ for text in ["\u{0E49}\u{0E49}\u{0E33}", "เ", "ก\u{200D}ข\u{200C}ค", "\u{1000}\u{103A}\u{200D}\u{1004}", &long] {
+ let chars: Vec = text.chars().collect();
+ let breaks = word_breaks(&chars);
+ assert_eq!(breaks.len(), chars.len(), "{text:?}");
+ for (k, c) in chars.iter().enumerate() {
+ assert!(
+ !(breaks.get(k).copied().unwrap_or(false) && is_trailing_mark(*c)),
+ "{text:?}: a word starts on mark U+{:04X} at {k}",
+ *c as u32
+ );
+ }
+ }
+ }
}
diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs
index d62948ae..3ccaa8ef 100644
--- a/crates/text/src/fontdb.rs
+++ b/crates/text/src/fontdb.rs
@@ -365,6 +365,16 @@ fn norm_style(s: &str) -> String {
s.to_lowercase().replace([' ', '-', '_'], "")
}
+/// A style name that says italic.
+fn italic_name(s: &str) -> bool {
+ s.to_lowercase().contains("italic")
+}
+
+/// `f` is `style` by name: its own, or one of its named instances.
+fn has_style(f: &Face, style: &str) -> bool {
+ norm_style(&f.style) == norm_style(style) || f.instance(style).is_some()
+}
+
/// A style name's weight before its file is read: the usual names, and Hiragino's "W3" (300).
fn pending_weight(style: &str) -> i32 {
let w = norm_style(style).strip_prefix('w').and_then(|n| n.parse::().ok()).filter(|n| (0..=9).contains(n));
@@ -503,6 +513,20 @@ struct Pending {
style: String,
/// The face's PostScript name.
postscript: Option,
+ /// A font the browser named ([`LocalFont`]), by its number there: its bytes are asked for
+ /// ([`take_wanted_local_fonts`]) when text uses it, not read from `path`.
+ local: Option,
+}
+
+/// A font installed on this computer that the browser named (Local Font Access, the web build):
+/// known by name until text uses it, then its bytes are asked for ([`take_wanted_local_fonts`]).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct LocalFont {
+ /// Its number for the browser layer, which keeps the font's handle.
+ pub id: usize,
+ pub family: String,
+ pub style: String,
+ pub postscript: String,
}
#[derive(Default)]
@@ -517,6 +541,10 @@ pub struct FontDb {
/// Font files added as bytes (hash and length → families), so loading one again adds nothing.
loaded_bytes: std::collections::HashMap<(u64, usize), Vec>,
system_scanned: bool,
+ /// Browser fonts text asked for and not yet taken by the browser layer.
+ wanted: Vec,
+ /// Browser fonts asked for whose bytes haven't come.
+ asked: std::collections::BTreeSet,
}
/// What a later walk of the font folders found ([`rescan_system_fonts`]).
@@ -608,9 +636,11 @@ impl FontDb {
/// Style names of a family, by weight then italic.
pub fn styles(&mut self, family: &str) -> Vec {
self.load_family(family, None);
- let Some((_, idx)) = self.families.get(&self.key(family)) else { return vec![] };
+ let key = self.key(family);
// (weight, italic, order in the font, name): named instances keep the font's order.
- let mut v: Vec<(u16, bool, usize, String)> = Vec::new();
+ let mut v: Vec<(u16, bool, usize, String)> =
+ self.waiting_local(&key).map(|e| (pending_weight(&e.style).clamp(1, 1000) as u16, italic_name(&e.style), 0, e.style.clone())).collect();
+ let idx = self.families.get(&key).map(|(_, idx)| idx.as_slice()).unwrap_or_default();
for f in idx.iter().filter_map(|i| self.faces.get(*i)) {
if f.instances.is_empty() {
v.push((f.weight, f.italic, 0, f.style.clone()));
@@ -620,7 +650,7 @@ impl FontDb {
let wght = f.axes.iter().position(|a| a.tag == "wght");
for (k, inst) in f.instances.iter().enumerate() {
let w = wght.and_then(|i| inst.coords.get(i)).map(|w| w.round().clamp(1.0, 1000.0) as u16).unwrap_or(f.weight);
- v.push((w, f.italic || inst.name.to_lowercase().contains("italic"), k, inst.name.clone()));
+ v.push((w, f.italic || italic_name(&inst.name), k, inst.name.clone()));
}
}
v.sort();
@@ -663,7 +693,7 @@ impl FontDb {
let Some((_, pending)) = self.pending.get_mut(&key) else { return };
let (want, want_w) = (norm_style(style), pending_weight(style));
let cost = |e: &Pending| {
- let italic = if e.style.to_lowercase().contains("italic") { 1000 } else { 0 };
+ let italic = if italic_name(&e.style) { 1000 } else { 0 };
if norm_style(&e.style) == want { 0 } else { 1 + (pending_weight(&e.style) - want_w).abs() + italic }
};
let Some(i) = pending.iter().enumerate().min_by_key(|(_, e)| cost(e)).map(|(i, _)| i) else { return };
@@ -674,6 +704,7 @@ impl FontDb {
vec![one]
}
};
+ let entries = self.keep_local(&key, display.as_deref().unwrap_or(family), entries, only.is_some());
let mut by_path: BTreeMap>> = BTreeMap::new();
for e in entries {
let bytes = match by_path.get(&e.path) {
@@ -697,6 +728,11 @@ impl FontDb {
/// The best face for `family` + `style` (exact style name, else nearest weight/italic).
pub fn find(&mut self, family: &str, style: &str) -> Option> {
self.load_family(family, None);
+ // A font the browser named is read one style at a time: the one asked for, unless a face
+ // read already has it.
+ if self.waiting_local(&self.key(family)).next().is_some() && !self.best(family, style).is_some_and(|f| has_style(&f, style)) {
+ self.load_family(family, Some(style));
+ }
self.best(family, style)
}
@@ -715,7 +751,7 @@ impl FontDb {
let (_, idx) = self.families.get(&self.key(family))?;
let faces: Vec<&Arc> = idx.iter().filter_map(|i| self.faces.get(*i)).collect();
let want = norm_style(style);
- if let Some(f) = faces.iter().find(|f| norm_style(&f.style) == want).or_else(|| faces.iter().find(|f| f.instance(style).is_some())) {
+ if let Some(f) = faces.iter().find(|f| norm_style(&f.style) == want).or_else(|| faces.iter().find(|f| has_style(f, style))) {
return Some((*f).clone());
}
let want_w = i32::from(figcraft_doc::style_weight(style));
@@ -780,6 +816,71 @@ impl FontDb {
self.faces.len()
}
+ /// Of `entries` (taken from a family's pending ones), those to read from disk: the browser's
+ /// go back to wait (under `display`), and with `ask` are asked for ([`take_wanted_local_fonts`]).
+ fn keep_local(&mut self, key: &str, display: &str, entries: Vec, ask: bool) -> Vec {
+ let (local, files): (Vec, Vec) = entries.into_iter().partition(|e| e.local.is_some());
+ if local.is_empty() {
+ return files;
+ }
+ for id in local.iter().filter_map(|e| e.local).filter(|_| ask) {
+ if self.asked.insert(id) {
+ self.wanted.push(id);
+ }
+ }
+ self.pending.entry(key.to_string()).or_insert_with(|| (display.to_string(), Vec::new())).1.extend(local);
+ files
+ }
+
+ /// The browser fonts of family `key` not read yet.
+ fn waiting_local(&self, key: &str) -> impl Iterator {
+ self.pending.get(key).into_iter().flat_map(|(_, v)| v).filter(|e| e.local.is_some())
+ }
+
+ /// `family` is known only by the names the browser gave its fonts: no face of it is read.
+ pub fn local_only(&self, family: &str) -> bool {
+ let key = self.key(family);
+ !self.families.contains_key(&key) && self.waiting_local(&key).next().is_some()
+ }
+
+ /// Offer the fonts the browser named: they join the families by name (a family the app has
+ /// keeps its faces, and the styles it lacks wait beside them). The families that were unknown.
+ pub fn add_local(&mut self, fonts: Vec) -> Vec {
+ let before: std::collections::BTreeSet = self.families().into_iter().collect();
+ let mut found: BTreeMap)> = BTreeMap::new();
+ for f in fonts.into_iter().filter(|f| !f.family.trim().is_empty()) {
+ let family = f.family.trim().to_string();
+ let e = Pending {
+ path: std::path::PathBuf::new(),
+ index: 0,
+ style: f.style,
+ postscript: Some(f.postscript).filter(|p| !p.is_empty()),
+ local: Some(f.id),
+ };
+ found.entry(family.to_lowercase()).or_insert_with(|| (family, Vec::new())).1.push(e);
+ }
+ self.merge_scan(found);
+ self.families().into_iter().filter(|f| !before.contains(f)).collect()
+ }
+
+ /// The bytes of browser font `id` came (empty: it couldn't be read): its face joins the family
+ /// under the name and style it was offered with. The family, or `None` when they aren't a font
+ /// (it is dropped then) or nothing waits for them.
+ pub fn load_local(&mut self, id: usize, bytes: Vec) -> Option {
+ self.asked.remove(&id);
+ let (key, i) = self.pending.iter().find_map(|(k, (_, v))| v.iter().position(|e| e.local == Some(id)).map(|i| (k.clone(), i)))?;
+ let (display, entries) = self.pending.get_mut(&key)?;
+ let (display, e) = (display.clone(), entries.remove(i));
+ if entries.is_empty() {
+ self.pending.remove(&key);
+ }
+ // In a collection, the face with its PostScript name.
+ let index = faces_in(&bytes).into_iter().find(|f| f.3.is_some() && f.3 == e.postscript).map_or(0, |f| f.0);
+ let face = Face::from_bytes(FontBytes::Shared(Arc::new(bytes)), index, Some(&display), Some(&e.style))?;
+ self.add(face);
+ Some(display)
+ }
+
/// Offer the families of a later walk of the font folders: new families become pending, a
/// family not loaded yet takes the walk's files, and a loaded family keeps its faces and gets
/// the files it hasn't read (styles installed since) as pending, read when it is next used.
@@ -953,6 +1054,7 @@ fn index_font_file(path: &std::path::Path, meta: &std::fs::Metadata, out: &mut B
index,
style,
postscript,
+ local: None,
});
}
}
@@ -1179,6 +1281,37 @@ pub fn load_font_bytes(bytes: Vec) -> Vec {
families
}
+/// Offer the fonts installed on this computer that the browser named ([`LocalFont`]): they join
+/// the font list by name, and their bytes are asked for ([`take_wanted_local_fonts`]) when text
+/// uses them. The families that were unknown.
+pub fn add_local_fonts(fonts: Vec) -> Vec {
+ let added = db().write().unwrap_or_else(std::sync::PoisonError::into_inner).add_local(fonts);
+ // Text that fell back while its family was unknown is laid out again (and asks for it).
+ crate::clear_layout_cache();
+ added
+}
+
+/// The browser fonts text asked for since the last call, each once ([`load_local_font`] takes
+/// their bytes).
+pub fn take_wanted_local_fonts() -> Vec {
+ std::mem::take(&mut db().write().unwrap_or_else(std::sync::PoisonError::into_inner).wanted)
+}
+
+/// Browser font `id`'s bytes (empty: they couldn't be read, and it is dropped): the family its
+/// face joined, if they were a font.
+pub fn load_local_font(id: usize, bytes: Vec) -> Option {
+ let family = db().write().unwrap_or_else(std::sync::PoisonError::into_inner).load_local(id, bytes);
+ if family.is_some() {
+ crate::clear_layout_cache();
+ }
+ family
+}
+
+/// `family` is known only by the names the browser gave its fonts (none read yet).
+pub fn local_only(family: &str) -> bool {
+ db().read().unwrap_or_else(std::sync::PoisonError::into_inner).local_only(family)
+}
+
/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`), or the process opted out
/// ([`opt_out_of_system_fonts`]): renders use only the embedded fonts.
#[cfg(not(target_arch = "wasm32"))]
@@ -1223,3 +1356,7 @@ pub fn face_with_char(c: char, families: &[&str]) -> Option> {
#[cfg(test)]
#[path = "fontdb_tests.rs"]
mod tests;
+
+#[cfg(test)]
+#[path = "fontdb_local_tests.rs"]
+mod local_tests;
diff --git a/crates/text/src/fontdb_local_tests.rs b/crates/text/src/fontdb_local_tests.rs
new file mode 100644
index 00000000..6c1b6466
--- /dev/null
+++ b/crates/text/src/fontdb_local_tests.rs
@@ -0,0 +1,97 @@
+//! Fonts installed on this computer that the browser named (#1051, Local Font Access): they join
+//! the font list by name, list their styles without being read, and a font's bytes are asked for
+//! once, only for the style text uses; when they come the face draws under the name it was
+//! offered with, and one that can't be read is dropped.
+
+use std::sync::Arc;
+
+use super::{CRAFT_FONTS, Face, FontBytes, FontDb, LocalFont, add_local_fonts, load_local_font, local_only, take_wanted_local_fonts};
+
+fn local(id: usize, family: &str, style: &str) -> LocalFont {
+ LocalFont { id, family: family.into(), style: style.into(), postscript: format!("{}-{}", family.replace(' ', ""), style) }
+}
+
+fn offered() -> FontDb {
+ let mut db = FontDb::default();
+ let added = db.add_local(vec![
+ local(0, "Shop Sans", "Bold"),
+ local(1, "Shop Sans", "Regular"),
+ local(2, "Shop Sans", "Light Italic"),
+ local(3, "", "Regular"),
+ ]);
+ assert_eq!(added, ["Shop Sans"], "a font without a family name isn't offered");
+ db
+}
+
+#[test]
+fn local_fonts_are_listed_by_name_and_read_one_style_at_a_time() {
+ let mut db = offered();
+ assert_eq!(db.families(), ["Shop Sans"]);
+ assert!(db.has_family("shop sans") && db.local_only("Shop Sans"));
+ assert_eq!(db.postscript_face("ShopSans-Bold"), Some(("Shop Sans".into(), "Bold".into())));
+ // Listing the styles reads nothing.
+ assert_eq!(db.styles("Shop Sans"), ["Light Italic", "Regular", "Bold"]);
+ assert!(db.wanted.is_empty());
+ // Text in the family asks for the style it uses, once; nothing draws until it comes.
+ assert!(db.find("Shop Sans", "Regular").is_none());
+ assert!(db.find("Shop Sans", "Regular").is_none());
+ assert!(db.find_one("Shop Sans", "Regular").is_none());
+ assert_eq!(db.wanted, [1]);
+ // The nearest style when there is no such name.
+ db.find("Shop Sans", "Black");
+ assert_eq!(db.wanted, [1, 0]);
+ assert_eq!(db.styles("Shop Sans").len(), 3, "still offered while they're read");
+}
+
+#[test]
+fn a_font_that_cant_be_read_is_dropped() {
+ let mut db = offered();
+ db.find("Shop Sans", "Bold");
+ assert_eq!(db.load_local(0, b"not a font".to_vec()), None);
+ assert_eq!(db.styles("Shop Sans"), ["Light Italic", "Regular"]);
+ assert_eq!(db.load_local(0, Vec::new()), None, "only once");
+ // Asked for again, it is.
+ db.find("Shop Sans", "Light Italic");
+ assert_eq!(db.wanted, [0, 2]);
+}
+
+#[test]
+fn a_local_font_draws_under_the_name_it_was_offered_with() {
+ // An embedded font stands in for the one on the computer (CI builds have craft-fonts).
+ let Some(bytes) = CRAFT_FONTS.first().map(|f| f.bytes.to_vec()) else {
+ eprintln!("skipping: no embedded font to stand in for an installed one");
+ return;
+ };
+ let mut db = offered();
+ db.find("Shop Sans", "Regular");
+ assert_eq!(db.load_local(1, bytes.clone()).as_deref(), Some("Shop Sans"));
+ let face = db.find("Shop Sans", "Regular").expect("the face");
+ assert_eq!((face.family.as_str(), face.style.as_str()), ("Shop Sans", "Regular"));
+ assert!(!db.local_only("Shop Sans"), "a face is read");
+ assert_eq!(db.wanted, [1], "nothing more asked for");
+ // The family keeps its other styles waiting.
+ let styles = db.styles("Shop Sans");
+ assert!(styles.iter().any(|s| s == "Bold") && styles.iter().any(|s| s == "Light Italic"), "{styles:?}");
+ // A family the app has keeps its faces; the computer's styles it lacks wait beside them.
+ let mut db = FontDb::default();
+ let Some(mut face) = Face::from_bytes(FontBytes::Shared(Arc::new(bytes)), 0, Some("Brand Sans"), Some("Regular")) else { panic!("not a font") };
+ // A variable stand-in's named instances aren't this family's styles.
+ face.instances.clear();
+ db.add(face);
+ assert!(db.add_local(vec![local(5, "Brand Sans", "Regular"), local(6, "Brand Sans", "Bold")]).is_empty(), "not a new family");
+ assert!(db.find("Brand Sans", "Regular").is_some() && db.wanted.is_empty());
+ db.find("Brand Sans", "Bold");
+ assert_eq!(db.wanted, [6]);
+}
+
+#[test]
+fn the_global_db_takes_local_fonts() {
+ let added = add_local_fonts(vec![local(900, "Global Local Sans", "Regular")]);
+ assert_eq!(added, ["Global Local Sans"]);
+ assert!(local_only("Global Local Sans") && !local_only("No Such Family"));
+ assert!(super::find_face("Global Local Sans", "Regular").is_none_or(|f| f.family != "Global Local Sans"));
+ assert!(take_wanted_local_fonts().contains(&900));
+ assert!(!take_wanted_local_fonts().contains(&900), "taken once");
+ assert_eq!(load_local_font(900, Vec::new()), None);
+ assert!(!super::has_family("Global Local Sans"));
+}
diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs
index 33cebe17..7e7222c3 100644
--- a/crates/text/src/fontdb_tests.rs
+++ b/crates/text/src/fontdb_tests.rs
@@ -2,8 +2,10 @@ use super::*;
fn pending(styles: &[&str]) -> FontDb {
let mut db = FontDb::default();
- let entries =
- styles.iter().map(|s| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: (*s).into(), postscript: None }).collect();
+ let entries = styles
+ .iter()
+ .map(|s| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: (*s).into(), postscript: None, local: None })
+ .collect();
db.pending.insert("cjk ui".into(), ("CJK UI".into(), entries));
db
}
@@ -271,7 +273,7 @@ fn the_suite_font_store_is_scanned() {
/// A rescan offers new families and the styles installed since, and never a file twice.
#[test]
fn a_rescan_offers_fonts_installed_since() {
- let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into(), postscript: None };
+ let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into(), postscript: None, local: None };
let mut db = pending(&["Regular"]);
let found = |fams: &[(&str, &[&str])]| -> BTreeMap)> {
fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect()
@@ -317,7 +319,7 @@ fn the_scan_offers_installed_styles_an_embedded_family_lacks() {
face.instances.clear();
let mut db = FontDb::default();
db.add(face);
- let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into(), postscript: None };
+ let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into(), postscript: None, local: None };
let found =
|family: &str, styles: &[&str]| BTreeMap::from([(family.to_lowercase(), (family.to_string(), styles.iter().map(|s| entry(s)).collect()))]);
db.merge_scan(found("Brand Sans", &["Regular"]));
@@ -354,7 +356,10 @@ fn apple_system_font_names_find_the_system_font() {
let mut db = FontDb::default();
db.pending.insert(
"system font".into(),
- ("System Font".into(), vec![Pending { path: "no-such-dir/SFNS.ttf".into(), index: 0, style: "Regular".into(), postscript: None }]),
+ (
+ "System Font".into(),
+ vec![Pending { path: "no-such-dir/SFNS.ttf".into(), index: 0, style: "Regular".into(), postscript: None, local: None }],
+ ),
);
for name in ["SF Pro", "SF Pro Text", "SF Pro Display", "SF UI Text", "SF UI Display", ".SF NS Text", "San Francisco"] {
assert!(db.has_family(name), "{name}");
@@ -425,7 +430,13 @@ fn faces_by_postscript_name() {
"brand sans".into(),
(
"Brand Sans".into(),
- vec![Pending { path: "no-such-dir/b.ttf".into(), index: 0, style: "Bold".into(), postscript: Some("BrandSans-Bold".into()) }],
+ vec![Pending {
+ path: "no-such-dir/b.ttf".into(),
+ index: 0,
+ style: "Bold".into(),
+ postscript: Some("BrandSans-Bold".into()),
+ local: None,
+ }],
),
);
assert_eq!(db.postscript_face("brandsans-bold"), Some(("Brand Sans".to_string(), "Bold".to_string())));
diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
index bb6cee7f..ceea978f 100644
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -331,8 +331,12 @@ struct ShapedText {
bidi: Option,
/// For each char, whether it starts a grapheme cluster.
cluster_start: Vec,
+ /// For each char, whether a Thai, Lao, Khmer or Burmese word starts there (empty without them).
+ word_breaks: Vec,
}
+/// Shape `text`: each span split by paragraph, bidi run and face into glyphs (items, in logical
+/// order), with the grapheme cluster starts and word breaks line breaking needs.
fn shape_text(text: &TextData) -> ShapedText {
let chars: Vec = text.characters.chars().collect();
let spans = text.spans();
@@ -383,33 +387,24 @@ fn shape_text(text: &TextData) -> ShapedText {
let Some(Some(primary)) = faces.get(st_idx) else { continue };
let size = sanitize_size(style.font_size);
let spacing = style.letter_spacing.resolve(size);
+ let span_fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks);
+ // The face for the grapheme cluster starting at `i` (clipped to the span).
+ let face_at = |i: usize| {
+ let to = (i + 1..*end).find(|&j| cluster_start.get(j).copied().unwrap_or(true)).unwrap_or(*end);
+ cluster_face(chars.get(i..to).unwrap_or(&[]), primary, &emoji, span_fallbacks)
+ };
// Segment by face.
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 is_line_break(c) || primary.has_char(c) {
- return primary.clone();
- }
- let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks);
- fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone())
- };
let first = chars.get(seg_start).copied().unwrap_or(' ');
- let face = face_for(first);
+ let face = face_at(seg_start);
let level = level_at(seg_start);
let mut seg_end = seg_start + 1;
while seg_end < *end
&& chars.get(seg_end).is_some_and(|c| {
!is_line_break(*c)
&& !is_line_break(first)
- && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id)
+ && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_at(seg_end).id == face.id)
})
&& level_at(seg_end) == level
{
@@ -439,7 +434,46 @@ fn shape_text(text: &TextData) -> ShapedText {
}
}
- ShapedText { chars, styles, faces, fallbacks, items, bidi, cluster_start }
+ let word_breaks = crate::breaks::word_breaks(&chars);
+ ShapedText { chars, styles, faces, fallbacks, items, bidi, cluster_start, word_breaks }
+}
+
+/// The face a grapheme cluster `cl` is shaped with. A letter and its marks come from one face that
+/// has them all: a mark the letter's face lacks would draw as a missing glyph beside it, and a mark
+/// from another face would sit apart from it (#1478).
+fn cluster_face(cl: &[char], primary: &Arc, emoji: &[Arc], fallbacks: &[Arc]) -> Arc {
+ pick_cluster_face(cl, primary, emoji, fallbacks, |f, c| f.has_char(c), |f| f.has_color()).clone()
+}
+
+/// [`cluster_face`]'s rule over any kind of face (`has`: whether a face has a char's glyph,
+/// `color`: whether it has colour glyphs), so it can be checked without fonts.
+pub(crate) fn pick_cluster_face<'a, F>(
+ cl: &[char],
+ primary: &'a F,
+ emoji: &'a [F],
+ fallbacks: &'a [F],
+ has: impl Fn(&F, char) -> bool,
+ color: impl Fn(&F) -> bool,
+) -> &'a F {
+ let c = cl.first().copied().unwrap_or(' ');
+ // 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)
+ && !color(primary)
+ && let Some(f) = emoji.iter().find(|f| has(f, c))
+ {
+ return f;
+ }
+ // Joiners and variation selectors need no glyph: shaping hides them.
+ let covers = |f: &F| {
+ cl.iter().all(|&m| has(f, m) || matches!(m, '\u{034F}' | '\u{200C}' | '\u{200D}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}'))
+ };
+ if is_line_break(c) || covers(primary) {
+ return primary;
+ }
+ // No face has the whole cluster: the letter's own face, as for a lone letter.
+ let own = || if has(primary, c) { Some(primary) } else { fallbacks.iter().find(|f| has(f, c)) };
+ fallbacks.iter().find(|f| covers(f)).or_else(own).unwrap_or(primary)
}
fn line_break_after(items: &[Item], end: usize) -> bool {
@@ -470,16 +504,43 @@ fn safe_break_index(items: &[Item], chars: &[char], cluster_start: &[bool], line
(line_start + 1..brk).rev().find(|&k| safe(k)).or(carry).unwrap_or(brk)
}
+/// How much wider than its box a saved line may come out in the fonts at hand before the saved
+/// breaks are given up: past that the font isn't the one the file was laid out with (a stand-in),
+/// and the text wraps by its own measure.
+const SAVED_LINE_SLACK: f64 = 1.03;
+
+/// Saved line starts being followed ([`TextData::saved_breaks_for`]).
+struct SavedStarts<'a> {
+ starts: &'a [usize],
+ next: usize,
+}
+
+impl SavedStarts<'_> {
+ /// Whether a line starting at char `from` breaks before char `at`: a saved start lies in
+ /// `(from, at]` (one inside a cluster or a run of spaces breaks at the next glyph after it).
+ fn due(&mut self, from: usize, at: usize) -> bool {
+ while self.starts.get(self.next).is_some_and(|s| *s <= from) {
+ self.next += 1;
+ }
+ self.starts.get(self.next).is_some_and(|s| *s <= at)
+ }
+}
+
+/// The lines (item range, paragraph end, paragraph), wrapped by measure or at `saved` line starts;
+/// None when a saved line would overflow its box by more than [`SAVED_LINE_SLACK`]. Lines break
+/// where [`crate::breaks::break_between`] allows, and between the words `word_breaks` marks.
fn break_lines(
items: &[Item],
chars: &[char],
cluster_start: &[bool],
+ word_breaks: &[bool],
wrap: Option,
line_indent_of: &impl Fn(usize, bool, usize) -> f64,
rtl_para_at: &impl Fn(usize) -> bool,
hang_start: bool,
hang_end: bool,
-) -> Vec<(usize, usize, bool, usize)> {
+ mut saved: Option,
+) -> Option> {
let mut lines: Vec<(usize, usize, bool, usize)> = Vec::new(); // item range, paragraph end, paragraph
let mut line_start = 0;
let mut i = 0;
@@ -517,21 +578,34 @@ fn break_lines(
let hung = items.get(line_start).filter(|f| hang_start && ltr && opens(f.ch)).map_or(0.0, |f| f.adv)
+ if hang_end && ltr && closes(it.ch) { w } else { 0.0 };
if let Some(maxw) = wrap
- && x + w - it.spacing > maxw - line_indent + hung + 1e-6
&& i > line_start
&& !it.is_space()
{
- let brk = safe_break_index(items, chars, cluster_start, line_start, last_break.filter(|b| *b >= line_start).map(|b| b + 1).unwrap_or(i));
- lines.push((line_start, brk, false, para));
- line_start = brk;
- i = brk;
- x = 0.0;
- last_break = None;
- para_first_line = false;
- continue;
+ let (width, room) = (x + w - it.spacing, maxw - line_indent + hung);
+ let line_char = items.get(line_start).map_or(0, |f| f.char_start);
+ let brk = match saved.as_mut().map(|s| s.due(line_char, it.char_start)) {
+ Some(true) => Some(i),
+ Some(false) if width > room * SAVED_LINE_SLACK + 1.0 => return None,
+ Some(false) => None,
+ None => (width > room + 1e-6).then(|| {
+ safe_break_index(items, chars, cluster_start, line_start, last_break.filter(|b| *b >= line_start).map(|b| b + 1).unwrap_or(i))
+ }),
+ };
+ if let Some(brk) = brk {
+ lines.push((line_start, brk, false, para));
+ line_start = brk;
+ i = brk;
+ x = 0.0;
+ last_break = None;
+ para_first_line = false;
+ continue;
+ }
}
x += w;
- if items.get(i + 1).is_some_and(|next| crate::breaks::break_between(it.ch, next.ch)) {
+ // A word starts at the next glyph only if that glyph starts a new cluster (marks are glyphs
+ // of their letter's cluster).
+ let word_start = |next: &Item| it.char_end <= next.char_start && word_breaks.get(next.char_start).copied().unwrap_or(false);
+ if items.get(i + 1).is_some_and(|next| crate::breaks::break_between(it.ch, next.ch) || word_start(next)) {
last_break = Some(i);
}
i += 1;
@@ -540,7 +614,7 @@ fn break_lines(
lines.push((line_start, items.len(), true, para));
}
- lines
+ Some(lines)
}
#[derive(Clone, Copy)]
@@ -752,7 +826,7 @@ fn finish_layout(out: &mut TextLayout, finish: LayoutFinish<'_>) {
/// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width
/// pass `None`). `box_height` is used for truncation only.
pub fn layout(text: &TextData, box_width: Option, box_height: Option) -> TextLayout {
- let ShapedText { chars, styles, faces, fallbacks, items, bidi, cluster_start } = shape_text(text);
+ let ShapedText { chars, styles, faces, fallbacks, items, bidi, cluster_start, word_breaks } = shape_text(text);
let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false);
// List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the
@@ -788,7 +862,10 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option)
TextAutoResize::WidthAndHeight => None,
_ => box_width.filter(|w| w.is_finite() && *w > 0.0),
};
- let lines = break_lines(&items, &chars, &cluster_start, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end);
+ let breaks = |saved| break_lines(&items, &chars, &cluster_start, &word_breaks, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end, saved);
+ // Where the file saved its line breaks, they win while the text and box are as saved.
+ let saved = wrap.and_then(|w| text.saved_breaks_for(w)).map(|starts| SavedStarts { starts, next: 0 });
+ let lines = saved.and_then(|s| breaks(Some(s))).or_else(|| breaks(None)).unwrap_or_default();
// Line metrics and positions.
let default_style = styles.first().cloned().unwrap_or_default();
let default_face = faces.first().cloned().flatten();
diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs
index 8da0b2d8..772e32e0 100644
--- a/crates/text/src/lib.rs
+++ b/crates/text/src/lib.rs
@@ -16,9 +16,9 @@ mod onpath;
pub use breaks::{backspace_step, grapheme_step};
pub use color::{ColorBitmap, ColorInk, ColorLayer};
pub use fontdb::{
- Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face,
- has_family, installed_fonts_known, load_font_bytes, no_coords, opt_out_of_system_fonts, rescan_system_fonts, scan_system_fonts,
- scan_system_fonts_on_demand, system_fonts_scanned,
+ Axis, CRAFT_FONTS, Coords, Face, FontDb, LocalFont, NamedInstance, Rescan, add_local_fonts, axis_label, db, face_with_char, family_names,
+ family_styles, find_face, has_family, installed_fonts_known, load_font_bytes, load_local_font, local_only, no_coords, opt_out_of_system_fonts,
+ rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, take_wanted_local_fonts,
};
pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
pub use onpath::OnPath;
@@ -161,6 +161,10 @@ mod onpath_tests;
#[cfg(test)]
mod rtl_list_tests;
#[cfg(test)]
+mod saved_breaks_tests;
+#[cfg(test)]
mod tail_tests;
#[cfg(test)]
mod tests;
+#[cfg(test)]
+mod vietnamese_tests;
diff --git a/crates/text/src/saved_breaks_tests.rs b/crates/text/src/saved_breaks_tests.rs
new file mode 100644
index 00000000..faf14b70
--- /dev/null
+++ b/crates/text/src/saved_breaks_tests.rs
@@ -0,0 +1,95 @@
+//! Line breaks a file saved with its text (an imported `.fig`'s layout): followed while the text
+//! and its box are as saved, so a font whose advances differ slightly from the file's doesn't move
+//! a word to the next line or split one, and dropped by any edit that could change the wrap.
+
+use std::sync::Arc;
+
+use figcraft_doc::{TextAutoResize, TextData};
+
+use super::{db, layout, layout_cached, scan_system_fonts};
+
+/// A 20 px auto-height text in the default face; None (skip) on a machine without fonts.
+fn text(s: &str) -> Option {
+ scan_system_fonts();
+ let f = db().write().unwrap().default_face()?;
+ let mut t = TextData::new(s);
+ (t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone());
+ t.style.font_size = 20.0;
+ t.auto_resize = TextAutoResize::Height;
+ Some(t)
+}
+
+/// How wide `s` sets on one line.
+fn width(t: &TextData, s: &str) -> f64 {
+ layout(&TextData { characters: s.into(), auto_resize: TextAutoResize::WidthAndHeight, ..t.clone() }, None, None).width
+}
+
+fn lines(t: &TextData, w: f64) -> Vec {
+ let chars: Vec = t.characters.chars().collect();
+ layout(t, Some(w), None).lines.iter().map(|l| chars.get(l.char_start..l.char_end).unwrap_or(&[]).iter().collect()).collect()
+}
+
+#[test]
+fn saved_breaks_keep_a_word_our_metrics_would_push_to_the_next_line() {
+ let Some(mut t) = text("Lorem ipsum a") else { return };
+ // A box 1% narrower than "Lorem ipsum": the file's font fitted it, ours doesn't.
+ let w = width(&t, "Lorem ipsum") * 0.99;
+ assert_eq!(lines(&t, w), ["Lorem ", "ipsum a"], "unhinted, the word wraps early");
+ t.save_breaks(vec![12], w);
+ assert_eq!(lines(&t, w), ["Lorem ipsum ", "a"]);
+}
+
+#[test]
+fn saved_breaks_keep_a_word_whole_in_a_narrow_box() {
+ let Some(mut t) = text("Followers") else { return };
+ let w = width(&t, "Followers") * 0.99;
+ assert_eq!(lines(&t, w).len(), 2, "unhinted, the word splits");
+ t.save_breaks(vec![], w);
+ assert_eq!(lines(&t, w), ["Followers"]);
+}
+
+#[test]
+fn saved_breaks_stop_applying_after_a_layout_edit_or_resize() {
+ let Some(mut t) = text("Lorem ipsum a") else { return };
+ let w = width(&t, "Lorem ipsum") * 0.99;
+ t.save_breaks(vec![12], w);
+ // Links and vertical settings don't move breaks: the saved ones still hold.
+ let mut painted = t.clone();
+ painted.style.link = Some("https://example.com".into());
+ painted.paragraph_spacing = 8.0;
+ assert_eq!(lines(&painted, w), ["Lorem ipsum ", "a"]);
+ let mut typed = t.clone();
+ typed.characters = "Lorem ipsum ab".into();
+ let mut sized = t.clone();
+ sized.style.font_size = 21.0;
+ for (edited, why) in [(&typed, "chars"), (&sized, "size")] {
+ assert_eq!(edited.saved_breaks_for(w), None, "{why}");
+ }
+ assert_eq!(lines(&typed, w), ["Lorem ", "ipsum ab"]);
+ assert_eq!(t.saved_breaks_for(w + 1.0), None, "another box width");
+ // Hinted and unhinted texts are told apart by the layout cache.
+ let plain = TextData { saved_breaks: None, ..t.clone() };
+ assert!(!Arc::ptr_eq(&layout_cached(&t, Some(w), None), &layout_cached(&plain, Some(w), None)));
+}
+
+#[test]
+fn saved_breaks_that_would_overflow_the_box_are_dropped() {
+ let Some(mut t) = text("Lorem ipsum a") else { return };
+ // 10% narrower: a font that much wider isn't the one the file was laid out with.
+ let w = width(&t, "Lorem ipsum") * 0.9;
+ let own = lines(&t, w);
+ t.save_breaks(vec![12], w);
+ assert_eq!(lines(&t, w), own);
+}
+
+#[test]
+fn saved_breaks_leave_hard_breaks_and_bad_starts_alone() {
+ let Some(mut t) = text("Lorem ipsum a\nb") else { return };
+ let w = width(&t, "Lorem ipsum") * 0.99;
+ t.save_breaks(vec![12], w);
+ assert_eq!(lines(&t, w), ["Lorem ipsum ", "a\n", "b"]);
+ for junk in [vec![0], vec![12, 12], vec![15], vec![40]] {
+ t.save_breaks(junk.clone(), w);
+ assert_eq!(t.saved_breaks_for(w), None, "{junk:?}");
+ }
+}
diff --git a/crates/text/src/vietnamese_tests.rs b/crates/text/src/vietnamese_tests.rs
new file mode 100644
index 00000000..661573dc
--- /dev/null
+++ b/crates/text/src/vietnamese_tests.rs
@@ -0,0 +1,49 @@
+//! Vietnamese stacked diacritics (#1478): a letter and its marks are shaped with one face that has
+//! them all, whether written precomposed (NFC) or decomposed (NFD). The face rule is checked with
+//! stand-in faces, so it runs on any machine whatever fonts it has.
+
+use crate::layout::pick_cluster_face;
+
+/// A stand-in face: the chars it has glyphs for, and whether those are colour glyphs.
+struct Fake {
+ chars: &'static str,
+ color: bool,
+}
+
+const fn fake(chars: &'static str) -> Fake {
+ Fake { chars, color: false }
+}
+
+fn pick<'a>(cluster: &str, primary: &'a Fake, emoji: &'a [Fake], fallbacks: &'a [Fake]) -> &'a str {
+ let cl: Vec = cluster.chars().collect();
+ pick_cluster_face(&cl, primary, emoji, fallbacks, |f, c| f.chars.contains(c), |f| f.color).chars
+}
+
+#[test]
+fn a_cluster_takes_one_face_that_has_all_its_chars() {
+ let latin = fake("EU");
+ let full = [fake("EU\u{0302}\u{0309}\u{031B}\u{0301}")];
+ let marks_only = [fake("\u{0302}\u{0309}"), fake("EU\u{0302}\u{0309}")];
+ // The style's own face has the whole cluster: it keeps it.
+ assert_eq!(pick("E\u{0302}\u{0309}", &full[0], &[], &[]), full[0].chars);
+ // The style's face lacks a mark (Ể typed decomposed in a Latin-1 font): the first fallback with
+ // the letter and every mark, not one with the marks alone.
+ assert_eq!(pick("E\u{0302}\u{0309}", &latin, &[], &marks_only), "EU\u{0302}\u{0309}");
+ // Joiners and variation selectors need no glyph of their own.
+ assert_eq!(pick("E\u{200D}\u{FE0F}", &latin, &[], &full), "EU");
+ // No face has the whole cluster: the letter's own face, then the first fallback with the letter.
+ assert_eq!(pick("E\u{031B}\u{0303}", &latin, &[], &marks_only), "EU");
+ assert_eq!(pick("Q\u{0302}", &fake("U"), &[], &[fake("\u{0302}"), fake("Q")]), "Q");
+ assert_eq!(pick("Z\u{0302}", &latin, &[], &marks_only), "EU");
+ // A line break stays with the style's face.
+ assert_eq!(pick("\n", &latin, &[], &full), "EU");
+}
+
+#[test]
+fn an_emoji_takes_a_colour_face_unless_the_style_has_colour_glyphs() {
+ let emoji = [Fake { chars: "\u{1F600}\u{1F601}", color: true }];
+ let mono = fake("\u{1F600}");
+ assert_eq!(pick("\u{1F600}", &mono, &emoji, &[]), emoji[0].chars);
+ let colour = Fake { chars: "\u{1F600}", color: true };
+ assert!(std::ptr::eq(pick_cluster_face(&['\u{1F600}'], &colour, &emoji, &[], |f, c| f.chars.contains(c), |f| f.color), &colour));
+}
diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml
index a00004dc..8c9d2624 100644
--- a/crates/ui-egui/locales/ar.toml
+++ b/crates/ui-egui/locales/ar.toml
@@ -84,6 +84,7 @@ status = "draft — needs native review"
"Advanced stroke" = "إعدادات متقدمة للحد الخارجي"
"After delay" = "بعد تأخير"
"After delay {ms}ms" = "بعد تأخير {ms} مللي ثانية"
+"Agent" = "وكيل"
"Align" = "محاذاة"
"Align bottom" = "محاذاة لأسفل"
"Align center" = "محاذاة للوسط أفقيًا"
@@ -807,6 +808,7 @@ status = "draft — needs native review"
"Main menu" = "القائمة الرئيسية"
"Make a component" = "إنشاء مكوّن"
"Make slot" = "إنشاء خانة محتوى"
+"Slot name" = "اسم خانة المحتوى"
"Mar" = "مارس"
"Margin" = "الهامش"
"Mark all as read" = "تحديد الكل كمقروء"
@@ -1112,7 +1114,6 @@ status = "draft — needs native review"
"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" = "دفع"
@@ -1270,6 +1271,7 @@ status = "draft — needs native review"
"Search files and templates" = "بحث عن ملفات وقوالب"
"Search results" = "نتائج البحث"
"Search fonts" = "بحث في الخطوط"
+"Use fonts installed on this computer…" = "استخدام الخطوط المثبتة على هذا الحاسوب…"
"Search layers" = "بحث في الطبقات"
"Search pages" = "بحث في الصفحات"
"Search shortcuts" = "بحث في الاختصارات"
@@ -1316,7 +1318,7 @@ status = "draft — needs native review"
"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." = "تتطلب مشاركة ملف تطبيق سطح المكتب؛ ويمكنك الانضمام إلى ملف هنا."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "المشاركة عبر الإنترنت متوقفة: لم يُحدد خادم في التفضيلات. يمكنك هنا الانضمام إلى ملف مُشارك."
"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}"
@@ -1483,6 +1485,7 @@ status = "draft — needs native review"
"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 page keeps the owner token only while it is open. Copy it to manage the file later: 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" = "يحتوي هذا الملف على تغييرات غير محفوظة"
@@ -1557,6 +1560,7 @@ status = "draft — needs native review"
"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) واجهة رسوميات أخرى لتجربتها."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "جرّب FigCraft {apis}، ثم مرة أخرى بمتطلبات أقل على وحدة الرسوميات الموفّرة للطاقة. غالبًا ما يفيد تحديث تعريف الرسوميات."
"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" = "استخدام قيمة ثابتة"
@@ -1644,7 +1648,6 @@ status = "draft — needs native review"
"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" = "من Z إلى A"
@@ -2195,7 +2198,7 @@ status = "draft — needs native review"
"pref.showDimensions" = "إظهار الأبعاد على الكائنات"
"pref.startOnHome" = "البدء من الشاشة الرئيسية"
"pref.uiScale" = "حجم الواجهة"
-"pref.userName" = "اسمك في التعليقات"
+"pref.userName" = "اسمك"
"proto.action.add" = "إضافة إجراء للنموذج التفاعلي"
"proto.action.move" = "إعادة ترتيب إجراء النموذج التفاعلي"
"proto.action.remove" = "إزالة إجراء النموذج التفاعلي"
diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml
index b7d74e60..4d5f2a13 100644
--- a/crates/ui-egui/locales/bn.toml
+++ b/crates/ui-egui/locales/bn.toml
@@ -79,6 +79,7 @@ status = "draft — needs native review"
"Advanced stroke" = "উন্নত স্ট্রোক"
"After delay" = "বিলম্বের পর"
"After delay {ms}ms" = "{ms}ms বিলম্বের পর"
+"Agent" = "এজেন্ট"
"Align" = "সারিবদ্ধ করুন"
"Align bottom" = "নিচে সারিবদ্ধ করুন"
"Align center" = "কেন্দ্রে সারিবদ্ধ করুন"
@@ -775,6 +776,7 @@ status = "draft — needs native review"
"Main menu" = "প্রধান মেনু"
"Make a component" = "একটি কম্পোনেন্ট তৈরি করুন"
"Make slot" = "স্লট তৈরি করুন"
+"Slot name" = "স্লটের নাম"
"Mar" = "মার্চ"
"Margin" = "মার্জিন"
"Mark all as read" = "সব পঠিত হিসেবে চিহ্নিত করুন"
@@ -1075,7 +1077,6 @@ status = "draft — needs native review"
"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" = "পুশ"
@@ -1229,6 +1230,7 @@ status = "draft — needs native review"
"Search files and templates" = "ফাইল এবং টেমপ্লেট খুঁজুন"
"Search results" = "অনুসন্ধানের ফলাফল"
"Search fonts" = "ফন্ট খুঁজুন"
+"Use fonts installed on this computer…" = "এই কম্পিউটারে ইনস্টল করা ফন্ট ব্যবহার করুন…"
"Search layers" = "লেয়ার খুঁজুন"
"Search shortcuts" = "শর্টকাট খুঁজুন"
"Section" = "সেকশন"
@@ -1272,7 +1274,7 @@ status = "draft — needs native review"
"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." = "ফাইল শেয়ার করতে ডেস্কটপ অ্যাপ প্রয়োজন; এখানে আপনি একটিতে যুক্ত হতে পারবেন।"
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "অনলাইন শেয়ারিং বন্ধ আছে: প্রেফারেন্সেসে কোনো সার্ভার সেট করা নেই। এখানে আপনি একটি শেয়ার করা ফাইলে যুক্ত হতে পারবেন।"
"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}"
@@ -1436,6 +1438,7 @@ status = "draft — needs native review"
"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 page keeps the owner token only while it is open. Copy it to manage the file later: 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" = "এই ফাইলে সেভ করা হয়নি এমন পরিবর্তন রয়েছে"
@@ -1504,6 +1507,7 @@ status = "draft — needs native review"
"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 এর নাম বলে।"
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft {apis} চেষ্টা করেছে, তারপর শক্তি-সাশ্রয়ী GPU-তে কম চাহিদায় আবার চেষ্টা করেছে। গ্রাফিক্স ড্রাইভার আপডেট করা প্রায়ই সহায়ক হয়।"
"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" = "একটি নির্দিষ্ট মান ব্যবহার করুন"
@@ -1588,7 +1592,6 @@ status = "draft — needs native review"
"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" = "Z থেকে A"
diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml
index 04b3001d..9d629382 100644
--- a/crates/ui-egui/locales/cs.toml
+++ b/crates/ui-egui/locales/cs.toml
@@ -74,6 +74,7 @@ status = "draft — needs native review"
"Advanced stroke" = "Pokročilé nastavení tahu"
"After delay" = "Po prodlevě"
"After delay {ms}ms" = "Po prodlevě {ms} ms"
+"Agent" = "Agent"
"Align" = "Zarovnat"
"Align bottom" = "Zarovnat dolů"
"Align center" = "Zarovnat na střed"
@@ -760,6 +761,7 @@ status = "draft — needs native review"
"Main menu" = "Hlavní nabídka"
"Make a component" = "Vytvořit komponentu"
"Make slot" = "Vytvořit slot"
+"Slot name" = "Název slotu"
"Mar" = "března"
"Margin" = "Vnější okraj"
"Mark all as read" = "Označit vše jako přečtené"
@@ -1054,7 +1056,6 @@ status = "draft — needs native review"
"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í"
@@ -1204,6 +1205,7 @@ status = "draft — needs native review"
"Search assets" = "Hledat zdroje"
"Search comments" = "Hledat komentáře"
"Search fonts" = "Hledat písma"
+"Use fonts installed on this computer…" = "Použít písma nainstalovaná v tomto počítači…"
"Search layers" = "Hledat vrstvy"
"Search shortcuts" = "Hledat zkratky"
"Section" = "Sekce"
@@ -1247,7 +1249,7 @@ status = "draft — needs native review"
"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."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Online sdílení je vypnuté: v předvolbách není nastaven server. Zde se můžete připojit ke sdílenému souboru."
"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}"
@@ -1408,6 +1410,7 @@ status = "draft — needs native review"
"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 page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Tato stránka si token vlastníka pamatuje, jen dokud je otevřená. Zkopírujte ho pro pozdější správu souboru: 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"
@@ -1554,7 +1557,6 @@ status = "draft — needs native review"
"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"
@@ -2087,7 +2089,7 @@ status = "draft — needs native review"
"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"
+"pref.userName" = "Vaše jméno"
"proto.action.add" = "Přidat akci prototypu"
"proto.action.move" = "Změnit pořadí akce prototypu"
"proto.action.remove" = "Odebrat akci prototypu"
diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml
index fa5a02d8..ce9de2b4 100644
--- a/crates/ui-egui/locales/de.toml
+++ b/crates/ui-egui/locales/de.toml
@@ -76,6 +76,7 @@ status = "draft — needs native review"
"Advanced stroke" = "Erweiterte Kontur"
"After delay" = "Nach Verzögerung"
"After delay {ms}ms" = "Nach {ms} ms"
+"Agent" = "Agent"
"Align" = "Ausrichtung"
"Align bottom" = "Unten ausrichten"
"Align center" = "Zentriert ausrichten"
@@ -739,6 +740,7 @@ status = "draft — needs native review"
"Main component deleted" = "Hauptkomponente gelöscht"
"Main menu" = "Hauptmenü"
"Make slot" = "Slot erstellen"
+"Slot name" = "Slot-Name"
"Margin" = "Rand"
"Mark all as read" = "Alle als gelesen markieren"
"Mark as resolved" = "Als erledigt markieren"
@@ -1023,7 +1025,6 @@ status = "draft — needs native review"
"Published version {v} to the server" = "Version {v} auf dem Server veröffentlicht"
"Published version {v}: save the file so other files can use it" = "Version {v} veröffentlicht: Speichern Sie die Datei, damit andere Dateien sie verwenden können"
"Published · {people}" = "Veröffentlicht · {people}"
-"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Zum Veröffentlichen auf einem Server ist die Desktop-App nötig; im Browser können Sie Dateien eines Servers beitreten"
"Publishing with the server's key." = "Veröffentlichung mit dem Schlüssel des Servers."
"Publishing…" = "Wird veröffentlicht…"
"Push" = "Schieben"
@@ -1171,6 +1172,7 @@ status = "draft — needs native review"
"Search assets" = "Assets suchen"
"Search comments" = "Kommentare suchen"
"Search fonts" = "Schriften suchen"
+"Use fonts installed on this computer…" = "Auf diesem Computer installierte Schriften verwenden…"
"Search layers" = "Ebenen suchen"
"Search plugins" = "Plugins suchen"
"Search shortcuts" = "Tastenkürzel suchen"
@@ -1213,7 +1215,7 @@ status = "draft — needs native review"
"Share: live session, export" = "Teilen: Live-Sitzung, Export"
"Shared" = "Geteilt"
"Shared: copy its code from the file's menu" = "Geteilt: Kopieren Sie den Code über das Menü der Datei"
-"Sharing a file needs the desktop app; here you can join one." = "Zum Teilen einer Datei wird die Desktop-App benötigt; hier können Sie einer beitreten."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Online-Teilen ist aus: In den Einstellungen ist kein Server festgelegt. Hier können Sie einer geteilten Datei beitreten."
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Teilen im lokalen Netzwerk (Online-Teilen ist aus: In den Einstellungen ist kein Server festgelegt)."
"Sharing on your network · {people}" = "Wird in Ihrem Netzwerk geteilt · {people}"
"Sharing online · {people}" = "Wird online geteilt · {people}"
@@ -1368,6 +1370,7 @@ status = "draft — needs native review"
"This branch was merged or archived." = "Dieser Branch wurde zusammengeführt oder archiviert."
"This computer only" = "Nur dieser Computer"
"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." = "Dieser Computer merkt sich das Eigentümer-Token, sodass sich die Datei über die Liste „Online“ auf dem Startbildschirm verwalten lässt. Kopieren Sie es, um die Datei von woanders zu verwalten: Es ist ein Passwort für diese Datei und wird nicht noch einmal angezeigt."
+"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Diese Seite behält das Eigentümer-Token nur, solange sie geöffnet ist. Kopieren Sie es, um die Datei später zu verwalten: Es ist ein Passwort für diese Datei und wird nicht noch einmal angezeigt."
"This condition is edited as an expression" = "Diese Bedingung wird als Ausdruck bearbeitet"
"This file" = "Diese Datei"
"This file has unsaved changes" = "Diese Datei hat nicht gespeicherte Änderungen"
@@ -1434,6 +1437,7 @@ status = "draft — needs native review"
"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."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft hat {apis} versucht und dann noch einmal mit geringeren Anforderungen auf der stromsparenden GPU. Oft hilft es, den Grafiktreiber zu aktualisieren."
"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"
@@ -1515,7 +1519,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Ihre Kopie der Datei bleibt geöffnet"
"Your copy stays open; the file stays on the server" = "Ihre Kopie bleibt geöffnet; die Datei bleibt auf dem Server"
"Your name" = "Ihr Name"
-"Your name in comments" = "Ihr Name in Kommentaren"
"Your system asks for reduced motion: animations stay off." = "Ihr System verlangt reduzierte Bewegung: Animationen bleiben aus."
"Z" = "Z"
"Zoom" = "Zoom"
@@ -2041,7 +2044,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "Duplizierte Ebenen umbenennen"
"pref.showDimensions" = "Abmessungen an Objekten anzeigen"
"pref.startOnHome" = "Mit Start beginnen"
-"pref.userName" = "Ihr Name in Kommentaren"
+"pref.userName" = "Ihr Name"
"proto.action.add" = "Prototyp-Aktion hinzufügen"
"proto.action.move" = "Prototyp-Aktion neu anordnen"
"proto.action.remove" = "Prototyp-Aktion entfernen"
diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml
index b329884c..f99c18ac 100644
--- a/crates/ui-egui/locales/en.toml
+++ b/crates/ui-egui/locales/en.toml
@@ -78,6 +78,7 @@ name = "English"
"Advanced stroke" = "Advanced stroke"
"After delay" = "After delay"
"After delay {ms}ms" = "After delay {ms}ms"
+"Agent" = "Agent"
"Align" = "Align"
"Align bottom" = "Align bottom"
"Align center" = "Align center"
@@ -182,6 +183,7 @@ name = "English"
"Blend mode" = "Blend mode"
"Blue" = "Blue"
"Blur" = "Blur"
+"Board grid" = "Board grid"
"Boolean" = "Boolean"
"Boolean groups" = "Boolean groups"
"Both directions" = "Both directions"
@@ -612,6 +614,7 @@ name = "English"
"Go to {name}" = "Go to {name}"
"Grab bag" = "Grab bag"
"Gradient" = "Gradient"
+"Graph paper" = "Graph paper"
"Gray" = "Gray"
"Green" = "Green"
"Greyscale components and two example screens to sketch a flow with" = "Greyscale components and two example screens to sketch a flow with"
@@ -726,6 +729,7 @@ name = "English"
"Join with a code" = "Join with a code"
"Joined" = "Joined"
"Joined {file}" = "Joined {file}"
+"Joining live session…" = "Joining live session…"
"Joining…" = "Joining…"
"Join|stroke" = "Join"
"Jul" = "Jul"
@@ -809,6 +813,7 @@ name = "English"
"Main menu" = "Main menu"
"Make a component" = "Make a component"
"Make slot" = "Make slot"
+"Slot name" = "Slot name"
"Mar" = "Mar"
"Margin" = "Margin"
"Mark all as read" = "Mark all as read"
@@ -1117,7 +1122,6 @@ name = "English"
"Published version {v} to the server" = "Published version {v} to the server"
"Published version {v}: save the file so other files can use it" = "Published version {v}: save the file so other files can use it"
"Published · {people}" = "Published · {people}"
-"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "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 with the server's key."
"Publishing…" = "Publishing…"
"Push" = "Push"
@@ -1197,6 +1201,7 @@ name = "English"
"Reset adjustments" = "Reset adjustments"
"Reset all changes" = "Reset all changes"
"Reset all overrides on this layer" = "Reset all overrides on this layer"
+"Reset grid" = "Reset grid"
"Reset overrides" = "Reset overrides"
"Reset slot" = "Reset slot"
"Reset this override" = "Reset this override"
@@ -1275,6 +1280,7 @@ name = "English"
"Search files and templates" = "Search files and templates"
"Search results" = "Search results"
"Search fonts" = "Search fonts"
+"Use fonts installed on this computer…" = "Use fonts installed on this computer…"
"Search layers" = "Search layers"
"Search pages" = "Search pages"
"Search shortcuts" = "Search shortcuts"
@@ -1321,7 +1327,7 @@ name = "English"
"Share: live session, export" = "Share: live session, export"
"Shared" = "Shared"
"Shared: copy its code from the file's menu" = "Shared: copy its code from the file's menu"
-"Sharing a file needs the desktop app; here you can join one." = "Sharing a file needs the desktop app; here you can join one."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Online sharing is off: no server is set in Preferences. Here you can join a shared file."
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Sharing on the local network (online sharing is off: no server is set in Preferences)."
"Sharing on your network · {people}" = "Sharing on your network · {people}"
"Sharing online · {people}" = "Sharing online · {people}"
@@ -1424,6 +1430,7 @@ name = "English"
"Stroke style" = "Stroke style"
"Stroke weight" = "Stroke weight"
"Strokes" = "Strokes"
+"Strong line every" = "Strong line every"
"Style" = "Style"
"Styles" = "Styles"
"Stylistic set 1" = "Stylistic set 1"
@@ -1488,6 +1495,7 @@ name = "English"
"This branch was merged or archived." = "This branch was merged or archived."
"This computer only" = "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 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 page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again."
"This condition is edited as an expression" = "This condition is edited as an expression"
"This file" = "This file"
"This file has unsaved changes" = "This file has unsaved changes"
@@ -1563,6 +1571,7 @@ name = "English"
"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."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps."
"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"
@@ -1650,7 +1659,6 @@ name = "English"
"Your copy of the file stays open" = "Your copy of the file stays open"
"Your copy stays open; the file stays on the server" = "Your copy stays open; the file stays on the server"
"Your name" = "Your name"
-"Your name in comments" = "Your name in comments"
"Your system asks for reduced motion: animations stay off." = "Your system asks for reduced motion: animations stay off."
"Z" = "Z"
"Z to A" = "Z to A"
diff --git a/crates/ui-egui/locales/es-419.toml b/crates/ui-egui/locales/es-419.toml
index 5b9668da..89f854f0 100644
--- a/crates/ui-egui/locales/es-419.toml
+++ b/crates/ui-egui/locales/es-419.toml
@@ -10,6 +10,7 @@ name = "Español (Latinoamérica)"
status = "draft — needs native review"
[ui]
+"Use fonts installed on this computer…" = "Usar las fuentes instaladas en esta computadora…"
"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 agrega variables propias"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un resorte se reproduce hasta que se asienta: su masa, rigidez y amortiguación fijan cuánto dura"
"Add a category" = "Agregar una categoría"
diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml
index 844dbb27..6dd69248 100644
--- a/crates/ui-egui/locales/es.toml
+++ b/crates/ui-egui/locales/es.toml
@@ -101,6 +101,7 @@ status = "draft — needs native review"
"Advanced stroke" = "Trazo avanzado"
"After delay" = "Tras un retardo"
"After delay {ms}ms" = "Tras {ms}ms"
+"Agent" = "Agente"
"Align" = "Alineación"
"Align bottom" = "Alinear abajo"
"Align center" = "Alinear al centro"
@@ -800,6 +801,7 @@ status = "draft — needs native review"
"Main menu" = "Menú principal"
"Make a component" = "Crea un componente"
"Make slot" = "Convertir en ranura"
+"Slot name" = "Nombre de la ranura"
"Mar" = "mar"
"Margin" = "Margen"
"Mark all as read" = "Marcar todo como leído"
@@ -1107,7 +1109,6 @@ status = "draft — needs native review"
"Published version {v} to the server" = "Versión {v} publicada en el servidor"
"Published version {v}: save the file so other files can use it" = "Versión {v} publicada: guarda el archivo para que otros archivos puedan usarla"
"Published · {people}" = "Publicado · {people}"
-"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Para publicar en un servidor necesitas la app de escritorio; en el navegador puedes unirte a archivos de un servidor"
"Publishing with the server's key." = "Se publica con la clave del servidor."
"Publishing…" = "Publicando…"
"Push" = "Empujar"
@@ -1264,6 +1265,7 @@ status = "draft — needs native review"
"Search files and templates" = "Buscar archivos y plantillas"
"Search results" = "Resultados de búsqueda"
"Search fonts" = "Buscar fuentes"
+"Use fonts installed on this computer…" = "Usar las fuentes instaladas en este ordenador…"
"Search layers" = "Buscar capas"
"Search pages" = "Buscar páginas"
"Search shortcuts" = "Buscar atajos"
@@ -1309,7 +1311,7 @@ status = "draft — needs native review"
"Share: live session, export" = "Compartir: sesión en vivo, exportar"
"Shared" = "Compartido"
"Shared: copy its code from the file's menu" = "Compartido: copia su código desde el menú del archivo"
-"Sharing a file needs the desktop app; here you can join one." = "Compartir un archivo requiere la aplicación de escritorio; aquí puedes unirte a uno."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Compartir en línea está desactivado: no hay servidor en Preferencias. Aquí puedes unirte a un archivo compartido."
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Se comparte en la red local (compartir en línea está desactivado: no hay servidor en Preferencias)."
"Sharing on your network · {people}" = "Compartiendo en tu red · {people}"
"Sharing online · {people}" = "Compartiendo en línea · {people}"
@@ -1475,6 +1477,7 @@ status = "draft — needs native review"
"This branch was merged or archived." = "Esta rama se fusionó o archivó."
"This computer only" = "Solo este ordenador"
"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." = "Este ordenador recuerda el token de propietario, así que el archivo se puede gestionar desde la lista En línea de la pantalla de inicio. Cópialo para gestionar el archivo desde otro lugar: es una contraseña para este archivo y no se volverá a mostrar."
+"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Esta página guarda el token de propietario solo mientras está abierta. Cópialo para gestionar el archivo más tarde: es una contraseña para este archivo y no se volverá a mostrar."
"This condition is edited as an expression" = "Esta condición se edita como expresión"
"This file" = "Este archivo"
"This file has unsaved changes" = "Este archivo tiene cambios sin guardar"
@@ -1546,6 +1549,7 @@ status = "draft — needs native review"
"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."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft probó {apis} y luego otra vez con menos requisitos en la GPU de bajo consumo. Actualizar el controlador gráfico suele ayudar."
"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"
@@ -1632,7 +1636,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Tu copia del archivo sigue abierta"
"Your copy stays open; the file stays on the server" = "Tu copia sigue abierta; el archivo se queda en el servidor"
"Your name" = "Tu nombre"
-"Your name in comments" = "Tu nombre en los comentarios"
"Your system asks for reduced motion: animations stay off." = "Tu sistema pide reducir el movimiento: las animaciones siguen desactivadas."
"Z" = "Z"
"Z to A" = "De la Z a la A"
@@ -2184,7 +2187,7 @@ status = "draft — needs native review"
"pref.showDimensions" = "Mostrar dimensiones en los objetos"
"pref.startOnHome" = "Empezar en Inicio"
"pref.uiScale" = "Tamaño de la interfaz"
-"pref.userName" = "Tu nombre en los comentarios"
+"pref.userName" = "Tu nombre"
"proto.action.add" = "Añadir acción de prototipo"
"proto.action.move" = "Reordenar acción de prototipo"
"proto.action.remove" = "Quitar acción de prototipo"
diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml
index 55b094d0..5c5c8edb 100644
--- a/crates/ui-egui/locales/fi.toml
+++ b/crates/ui-egui/locales/fi.toml
@@ -80,6 +80,7 @@ status = "draft — needs native review"
"Advanced stroke" = "Reunaviivan lisäasetukset"
"After delay" = "Viiveen jälkeen"
"After delay {ms}ms" = "Viiveen {ms}ms jälkeen"
+"Agent" = "Agentti"
"Align" = "Tasaa"
"Align bottom" = "Tasaa alas"
"Align center" = "Keskitä"
@@ -777,6 +778,7 @@ status = "draft — needs native review"
"Main menu" = "Päävalikko"
"Make a component" = "Luo komponentti"
"Make slot" = "Luo sisältöpaikka"
+"Slot name" = "Sisältöpaikan nimi"
"Mar" = "maalisk."
"Margin" = "Marginaali"
"Mark all as read" = "Merkitse kaikki luetuiksi"
@@ -1079,7 +1081,6 @@ status = "draft — needs native review"
"Published version {v} to the server" = "Versio {v} julkaistu palvelimelle"
"Published version {v}: save the file so other files can use it" = "Versio {v} julkaistu: tallenna tiedosto, jotta muut tiedostot voivat käyttää sitä"
"Published · {people}" = "Julkaistu · {people}"
-"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Palvelimelle julkaiseminen vaatii työpöytäsovelluksen. Selaimessa voit liittyä palvelimen tiedostoihin."
"Publishing with the server's key." = "Julkaistaan palvelimen avaimella."
"Publishing…" = "Julkaistaan…"
"Push" = "Työntö"
@@ -1228,6 +1229,7 @@ status = "draft — needs native review"
"Search comments" = "Etsi kommentteja"
"Search files and templates" = "Etsi tiedostoja ja malleja"
"Search fonts" = "Etsi fontteja"
+"Use fonts installed on this computer…" = "Käytä tähän tietokoneeseen asennettuja fontteja…"
"Search layers" = "Etsi tasoja"
"Search plugins" = "Etsi liitännäisiä"
"Search results" = "Hakutulokset"
@@ -1276,7 +1278,7 @@ status = "draft — needs native review"
"Share: live session, export" = "Jaa: reaaliaikainen istunto, vienti"
"Shared" = "Jaettu"
"Shared: copy its code from the file's menu" = "Jaettu: kopioi koodi tiedoston valikosta"
-"Sharing a file needs the desktop app; here you can join one." = "Tiedoston jakaminen vaatii työpöytäsovelluksen. Tässä voit liittyä jaettuun tiedostoon."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Verkkojakaminen on pois käytöstä: asetuksissa ei ole palvelinta. Tässä voit liittyä jaettuun tiedostoon."
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Jaetaan lähiverkossa (verkkojakaminen on pois käytöstä: asetuksissa ei ole palvelinta)."
"Sharing on your network · {people}" = "Jaetaan lähiverkossasi · {people}"
"Sharing online · {people}" = "Jaetaan verkossa · {people}"
@@ -1441,6 +1443,7 @@ status = "draft — needs native review"
"This branch was merged or archived." = "Tämä haara on yhdistetty tai arkistoitu."
"This computer only" = "Vain tämä tietokone"
"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." = "Tämä tietokone muistaa omistajan tunnisteen, joten tiedostoa voi hallita aloitusnäkymän Verkossa-luettelosta. Kopioi tunniste hallitaksesi tiedostoa muualla. Se on tämän tiedoston salasana, eikä sitä näytetä uudelleen."
+"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Tämä sivu säilyttää omistajan tunnisteen vain niin kauan kuin se on auki. Kopioi tunniste hallitaksesi tiedostoa myöhemmin. Se on tämän tiedoston salasana, eikä sitä näytetä uudelleen."
"This condition is edited as an expression" = "Tätä ehtoa muokataan lausekkeena"
"This file" = "Tämä tiedosto"
"This file has unsaved changes" = "Tässä tiedostossa on tallentamattomia muutoksia"
@@ -1509,6 +1512,7 @@ status = "draft — needs native review"
"Updated {name}" = "Päivitetty: {name}"
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Näytönohjainajurin päivittäminen auttaa usein. Käytä vain OpenGL:ää käynnistämällä FigCraft komentokehotteesta näin:"
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Näytönohjainajurin päivittäminen auttaa usein. WGPU_BACKEND (vulkan, gl, metal) määrittää toisen kokeiltavan grafiikkarajapinnan."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft kokeili {apis} ja sitten uudelleen pienemmillä vaatimuksilla virtaa säästävällä näytönohjaimella. Näytönohjainajurin päivittäminen auttaa usein."
"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." = "Lataa tämän tiedoston FigCraft-palvelimelle ja pitää tämän välilehden synkronoituna sen kanssa. Saat muokkauslinkin ja katselulinkin."
"Uppercase" = "Isot kirjaimet"
"Use a fixed value" = "Käytä kiinteää arvoa"
@@ -1593,7 +1597,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Oma kopiosi tiedostosta pysyy auki"
"Your copy stays open; the file stays on the server" = "Oma kopiosi pysyy auki, ja tiedosto jää palvelimelle"
"Your name" = "Nimesi"
-"Your name in comments" = "Nimesi kommenteissa"
"Your system asks for reduced motion: animations stay off." = "Järjestelmäsi pyytää vähennettyä liikettä. Animaatiot pidetään pois käytöstä."
"Z" = "Z"
"Z to A" = "Z:sta A:han"
@@ -2133,7 +2136,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "Nimeä monistetut tasot uudelleen"
"pref.showDimensions" = "Näytä objektien mitat"
"pref.startOnHome" = "Aloita Aloitus-näkymästä"
-"pref.userName" = "Nimesi kommenteissa"
+"pref.userName" = "Nimesi"
"proto.action.add" = "Lisää prototyyppitoiminto"
"proto.action.move" = "Muuta prototyyppitoimintojen järjestystä"
"proto.action.remove" = "Poista prototyyppitoiminto"
diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml
index 2d9b9b76..f50150af 100644
--- a/crates/ui-egui/locales/fr.toml
+++ b/crates/ui-egui/locales/fr.toml
@@ -79,6 +79,7 @@ status = "draft — needs native review"
"Advanced stroke" = "Contour avancé"
"After delay" = "Après un délai"
"After delay {ms}ms" = "Après {ms} ms"
+"Agent" = "Agent"
"Align" = "Alignement"
"Align bottom" = "Aligner en bas"
"Align center" = "Aligner au centre"
@@ -777,6 +778,7 @@ status = "draft — needs native review"
"Main menu" = "Menu principal"
"Make a component" = "Créer un composant"
"Make slot" = "Créer un emplacement"
+"Slot name" = "Nom de l'emplacement"
"Mar" = "mars"
"Margin" = "Marge"
"Mark all as read" = "Tout marquer comme lu"
@@ -1078,7 +1080,6 @@ status = "draft — needs native review"
"Published version {v} to the server" = "Version {v} publiée sur le serveur"
"Published version {v}: save the file so other files can use it" = "Version {v} publiée : enregistrez le fichier pour que d’autres fichiers puissent l’utiliser"
"Published · {people}" = "Publié · {people}"
-"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publier sur un serveur nécessite l’application de bureau ; dans le navigateur, vous pouvez rejoindre les fichiers d’un serveur"
"Publishing with the server's key." = "Publication avec la clé du serveur."
"Publishing…" = "Publication…"
"Push" = "Pousser"
@@ -1228,6 +1229,7 @@ status = "draft — needs native review"
"Search assets" = "Rechercher des ressources"
"Search comments" = "Rechercher des commentaires"
"Search fonts" = "Rechercher des polices"
+"Use fonts installed on this computer…" = "Utiliser les polices installées sur cet ordinateur…"
"Search layers" = "Rechercher des calques"
"Search plugins" = "Rechercher des plugins"
"Search shortcuts" = "Rechercher des raccourcis"
@@ -1275,7 +1277,7 @@ status = "draft — needs native review"
"Share: live session, export" = "Partager : session en direct, exportation"
"Shared" = "Partagé"
"Shared: copy its code from the file's menu" = "Partagé : copiez son code depuis le menu du fichier"
-"Sharing a file needs the desktop app; here you can join one." = "Le partage d’un fichier nécessite l’application de bureau ; ici, vous pouvez en rejoindre un."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Le partage en ligne est désactivé : aucun serveur n’est défini dans les Préférences. Ici, vous pouvez rejoindre un fichier partagé."
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Partage sur le réseau local (le partage en ligne est désactivé : aucun serveur n’est défini dans les Préférences)."
"Sharing on your network · {people}" = "Partage sur votre réseau · {people}"
"Sharing online · {people}" = "Partage en ligne · {people}"
@@ -1441,6 +1443,7 @@ status = "draft — needs native review"
"This branch was merged or archived." = "Cette branche a été fusionnée ou archivée."
"This computer only" = "Cet ordinateur uniquement"
"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." = "Cet ordinateur mémorise le jeton de propriétaire : le fichier se gère depuis la liste En ligne de l’écran d’accueil. Copiez-le pour gérer le fichier depuis un autre endroit : c’est un mot de passe pour ce fichier et il ne sera plus affiché."
+"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Cette page ne garde le jeton de propriétaire que tant qu’elle est ouverte. Copiez-le pour gérer le fichier plus tard : c’est un mot de passe pour ce fichier et il ne sera plus affiché."
"This condition is edited as an expression" = "Cette condition se modifie en tant qu’expression"
"This file" = "Ce fichier"
"This file has unsaved changes" = "Ce fichier contient des modifications non enregistrées"
@@ -1509,6 +1512,7 @@ status = "draft — needs native review"
"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."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft a essayé {apis}, puis à nouveau avec des exigences réduites sur le GPU économe en énergie. Mettre à jour le pilote graphique aide souvent."
"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"
@@ -1596,7 +1600,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Votre copie du fichier reste ouverte"
"Your copy stays open; the file stays on the server" = "Votre copie reste ouverte ; le fichier reste sur le serveur"
"Your name" = "Votre nom"
-"Your name in comments" = "Votre nom dans les commentaires"
"Your system asks for reduced motion: animations stay off." = "Votre système demande de réduire les animations : elles restent désactivées."
"Z" = "Z"
"Z to A" = "Z à A"
@@ -2135,7 +2138,7 @@ status = "draft — needs native review"
"pref.showDimensions" = "Afficher les dimensions sur les objets"
"pref.startOnHome" = "Démarrer sur l’Accueil"
"pref.uiScale" = "Taille de l’interface"
-"pref.userName" = "Votre nom dans les commentaires"
+"pref.userName" = "Votre nom"
"proto.action.add" = "Ajouter une action de prototype"
"proto.action.move" = "Réordonner l’action de prototype"
"proto.action.remove" = "Retirer l’action de prototype"
diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml
index 172043f5..66764481 100644
--- a/crates/ui-egui/locales/id.toml
+++ b/crates/ui-egui/locales/id.toml
@@ -81,6 +81,7 @@ status = "draft — needs native review"
"Advanced stroke" = "Stroke lanjutan"
"After delay" = "Setelah jeda"
"After delay {ms}ms" = "Setelah jeda {ms}ms"
+"Agent" = "Agen"
"Align" = "Ratakan"
"Align bottom" = "Ratakan bawah"
"Align center" = "Rata tengah"
@@ -767,6 +768,7 @@ status = "draft — needs native review"
"Main menu" = "Menu utama"
"Make a component" = "Buat component"
"Make slot" = "Jadikan slot"
+"Slot name" = "Nama slot"
"Mar" = "Mar"
"Margin" = "Margin"
"Mark all as read" = "Tandai semua sudah dibaca"
@@ -1061,7 +1063,6 @@ status = "draft — needs native review"
"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"
@@ -1210,6 +1211,7 @@ status = "draft — needs native review"
"Search assets" = "Cari aset"
"Search comments" = "Cari komentar"
"Search fonts" = "Cari font"
+"Use fonts installed on this computer…" = "Gunakan font yang terpasang di komputer ini…"
"Search layers" = "Cari layer"
"Search shortcuts" = "Cari pintasan"
"Section" = "Section"
@@ -1253,7 +1255,7 @@ status = "draft — needs native review"
"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."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Berbagi online nonaktif: tidak ada server yang diatur di Preferensi. Di sini Anda dapat bergabung ke file yang dibagikan."
"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}"
@@ -1414,6 +1416,7 @@ status = "draft — needs native review"
"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 page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Halaman ini hanya menyimpan token pemilik selama halaman terbuka. Salin token untuk mengelola file nanti: 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"
@@ -1558,7 +1561,6 @@ status = "draft — needs native review"
"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"
@@ -2091,7 +2093,7 @@ status = "draft — needs native review"
"pref.reduceMotion" = "Kurangi gerakan"
"pref.renameDuplicates" = "Ganti nama layer duplikat"
"pref.showDimensions" = "Tampilkan dimensi pada objek"
-"pref.userName" = "Nama Anda di komentar"
+"pref.userName" = "Nama Anda"
"proto.action.add" = "Tambah aksi prototype"
"proto.action.move" = "Ubah urutan aksi prototype"
"proto.action.remove" = "Hapus aksi prototype"
diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml
index 0a9c6156..a4e65a87 100644
--- a/crates/ui-egui/locales/it.toml
+++ b/crates/ui-egui/locales/it.toml
@@ -74,6 +74,7 @@ status = "draft — needs native review"
"Advanced stroke" = "Contorno avanzato"
"After delay" = "Dopo un ritardo"
"After delay {ms}ms" = "Dopo un ritardo di {ms} ms"
+"Agent" = "Agente"
"Align" = "Allinea"
"Align bottom" = "Allinea in basso"
"Align center" = "Allinea al centro"
@@ -750,6 +751,7 @@ status = "draft — needs native review"
"Main menu" = "Menu principale"
"Make a component" = "Crea un componente"
"Make slot" = "Crea slot"
+"Slot name" = "Nome dello slot"
"Mar" = "mar"
"Margin" = "Margine"
"Mark all as read" = "Segna tutto come letto"
@@ -1042,7 +1044,6 @@ status = "draft — needs native review"
"Published version {v} to the server" = "Versione {v} pubblicata sul server"
"Published version {v}: save the file so other files can use it" = "Versione {v} pubblicata: salva il file perché altri file possano usarla"
"Published · {people}" = "Pubblicato · {people}"
-"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Per pubblicare su un server serve l'app desktop; nel browser puoi unirti ai file di un server"
"Publishing with the server's key." = "Pubblicazione con la chiave del server."
"Publishing…" = "Pubblicazione…"
"Push" = "Invia"
@@ -1189,6 +1190,7 @@ status = "draft — needs native review"
"Search files and templates" = "Cerca file e modelli"
"Search results" = "Risultati della ricerca"
"Search fonts" = "Cerca font"
+"Use fonts installed on this computer…" = "Usa i font installati su questo computer…"
"Search layers" = "Cerca livelli"
"Search pages" = "Cerca pagine"
"Search shortcuts" = "Cerca scorciatoie"
@@ -1232,7 +1234,7 @@ status = "draft — needs native review"
"Share: live session, export" = "Condividi: sessione live, esportazione"
"Shared" = "Condiviso"
"Shared: copy its code from the file's menu" = "Condiviso: copia il suo codice dal menu del file"
-"Sharing a file needs the desktop app; here you can join one." = "Per condividere un file serve l'app desktop; qui puoi partecipare a uno."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "La condivisione online è disattivata: nelle Preferenze non è impostato alcun server. Qui puoi partecipare a un file condiviso."
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Condivisione sulla rete locale (la condivisione online è disattivata: nelle Preferenze non è impostato alcun server)."
"Sharing on your network · {people}" = "Condivisione sulla tua rete · {people}"
"Sharing online · {people}" = "Condivisione online · {people}"
@@ -1387,6 +1389,7 @@ status = "draft — needs native review"
"This branch was merged or archived." = "Questo ramo è stato unito o archiviato."
"This computer only" = "Solo questo computer"
"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." = "Questo computer ricorda il token del proprietario, quindi il file può essere gestito dall'elenco Online della schermata Home. Copialo per gestire il file da un altro posto: è una password per questo file e non verrà mostrato di nuovo."
+"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Questa pagina conserva il token del proprietario solo finché resta aperta. Copialo per gestire il file in seguito: è una password per questo file e non verrà mostrato di nuovo."
"This condition is edited as an expression" = "Questa condizione viene modificata come espressione"
"This file" = "Questo file"
"This file has unsaved changes" = "Questo file ha modifiche non salvate"
@@ -1451,6 +1454,7 @@ status = "draft — needs native review"
"Updated {name}" = "Aggiornato {name}"
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Aggiornare il driver grafico spesso aiuta. Per usare solo OpenGL, avvia FigCraft dal Prompt dei comandi con:"
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Aggiornare il driver grafico spesso aiuta. WGPU_BACKEND (vulkan, gl, metal) indica un'altra API grafica da provare."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft ha provato {apis}, poi di nuovo con requisiti ridotti sulla GPU a basso consumo. Aggiornare il driver grafico spesso aiuta."
"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." = "Carica questo file su un server FigCraft e mantiene questa scheda sincronizzata con esso. Ricevi un link per modificare e un link per visualizzare."
"Uppercase" = "Maiuscolo"
"Use a fixed value" = "Usa un valore fisso"
@@ -1532,7 +1536,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "La tua copia del file resta aperta"
"Your copy stays open; the file stays on the server" = "La tua copia resta aperta; il file resta sul server"
"Your name" = "Il tuo nome"
-"Your name in comments" = "Il tuo nome nei commenti"
"Your system asks for reduced motion: animations stay off." = "Il tuo sistema richiede movimento ridotto: le animazioni restano disattivate."
"Z" = "Z"
"Z to A" = "Dalla Z alla A"
@@ -2048,7 +2051,7 @@ status = "draft — needs native review"
"pref.showDimensions" = "Mostra dimensioni sugli oggetti"
"pref.startOnHome" = "Avvia dalla schermata Home"
"pref.uiScale" = "Dimensione dell'interfaccia"
-"pref.userName" = "Il tuo nome nei commenti"
+"pref.userName" = "Il tuo nome"
"proto.action.add" = "Aggiungi azione del prototipo"
"proto.action.move" = "Riordina azione del prototipo"
"proto.action.remove" = "Rimuovi azione del prototipo"
diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml
index 417311b2..31647797 100644
--- a/crates/ui-egui/locales/ja.toml
+++ b/crates/ui-egui/locales/ja.toml
@@ -80,6 +80,7 @@ status = "draft — needs native review"
"Advanced stroke" = "詳細な線"
"After delay" = "遅延後"
"After delay {ms}ms" = "{ms}ms 遅延後"
+"Agent" = "エージェント"
"Align" = "整列"
"Align bottom" = "下揃え"
"Align center" = "中央揃え"
@@ -771,6 +772,7 @@ status = "draft — needs native review"
"Main menu" = "メインメニュー"
"Make a component" = "コンポーネントを作成"
"Make slot" = "スロットにする"
+"Slot name" = "スロット名"
"Mar" = "3月"
"Margin" = "マージン"
"Mark all as read" = "すべて既読にする"
@@ -1066,7 +1068,6 @@ status = "draft — needs native review"
"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" = "プッシュ"
@@ -1215,6 +1216,7 @@ status = "draft — needs native review"
"Search assets" = "アセットを検索"
"Search comments" = "コメントを検索"
"Search fonts" = "フォントを検索"
+"Use fonts installed on this computer…" = "このコンピューターにインストールされたフォントを使用…"
"Search layers" = "レイヤーを検索"
"Search plugins" = "プラグインを検索"
"Search shortcuts" = "ショートカットを検索"
@@ -1262,7 +1264,7 @@ status = "draft — needs native review"
"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." = "ファイルの共有にはデスクトップアプリが必要。ここでは参加のみ可能。"
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "オンライン共有はオフ:環境設定でサーバーが未設定。ここでは共有ファイルへの参加のみ可能。"
"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}"
@@ -1424,6 +1426,7 @@ status = "draft — needs native review"
"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 page keeps the owner token only while it is open. Copy it to manage the file later: 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" = "このファイルには未保存の変更がある"
@@ -1492,6 +1495,7 @@ status = "draft — needs native review"
"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 を試せる。"
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft は {apis} を試し、さらに省電力 GPU で要件を下げて再試行した。グラフィックスドライバーの更新で解決することが多い。"
"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" = "固定値を使用"
@@ -1577,7 +1581,6 @@ status = "draft — needs native review"
"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" = "降順(Z→A)"
@@ -2113,7 +2116,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "複製したレイヤーの名前を変更"
"pref.showDimensions" = "オブジェクトに寸法を表示"
"pref.startOnHome" = "起動時にホームを表示"
-"pref.userName" = "コメントに表示する名前"
+"pref.userName" = "あなたの名前"
"proto.action.add" = "プロトタイプのアクションを追加"
"proto.action.move" = "プロトタイプのアクションの順序を変更"
"proto.action.remove" = "プロトタイプのアクションを削除"
diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml
index 84597419..985c4845 100644
--- a/crates/ui-egui/locales/ka.toml
+++ b/crates/ui-egui/locales/ka.toml
@@ -81,6 +81,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Advanced stroke" = "კონტურის დამატებითი პარამეტრები"
"After delay" = "დაყოვნების შემდეგ"
"After delay {ms}ms" = "დაყოვნების შემდეგ: {ms} მწმ"
+"Agent" = "აგენტი"
"Align" = "სწორება"
"Align bottom" = "ქვემოთ სწორება"
"Align center" = "ცენტრში სწორება"
@@ -784,6 +785,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Main menu" = "მთავარი მენიუ"
"Make a component" = "შექმენით კომპონენტი"
"Make slot" = "სლოტად გადაქცევა"
+"Slot name" = "სლოტის სახელი"
"Mar" = "მარ"
"Margin" = "ველი"
"Mark all as read" = "ყველას წაკითხულად მარკირება"
@@ -1087,7 +1089,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"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" = "ბიძგი"
@@ -1246,6 +1247,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Search files and templates" = "ფაილებისა და შაბლონების ძიება"
"Search results" = "ძიების შედეგები"
"Search fonts" = "შრიფტების ძიება"
+"Use fonts installed on this computer…" = "ამ კომპიუტერზე დაყენებული შრიფტების გამოყენება…"
"Search layers" = "შრეების ძიება"
"Search shortcuts" = "მალსახმობების ძიება"
"Section" = "სექცია"
@@ -1289,7 +1291,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"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." = "ფაილის გასაზიარებლად სამუშაო მაგიდის აპია საჭირო; აქ შეგიძლიათ შეუერთდეთ."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "ონლაინ გაზიარება გამორთულია: პარამეტრებში სერვერი არ არის მითითებული. აქ შეგიძლიათ შეუერთდეთ გაზიარებულ ფაილს."
"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}"
@@ -1455,6 +1457,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"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 page keeps the owner token only while it is open. Copy it to manage the file later: 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" = "ამ ფაილს შეუნახავი ცვლილებები აქვს"
@@ -1527,6 +1530,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Updated {name}" = "განახლდა: {name}"
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "ხშირად გრაფიკის დრაივერის განახლება შველის. მხოლოდ OpenGL-ის გამოსაყენებლად FigCraft გაუშვით ბრძანების სტრიქონიდან (Command Prompt) ასე:"
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "ხშირად გრაფიკის დრაივერის განახლება შველის. WGPU_BACKEND-ით (vulkan, gl, metal) შეგიძლიათ სხვა გრაფიკული API სცადოთ."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft-მა სცადა {apis}, შემდეგ კი ისევ, ნაკლები მოთხოვნებით, ენერგოდამზოგ GPU-ზე. ხშირად გრაფიკის დრაივერის განახლება შველის."
"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" = "ფიქსირებული მნიშვნელობის გამოყენება"
@@ -1614,7 +1618,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"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" = "ჰ-დან ა-მდე"
@@ -2187,7 +2190,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"pref.showDimensions" = "ზომების ჩვენება ობიექტებზე"
"pref.startOnHome" = "საწყისი ეკრანით გაშვება"
"pref.uiScale" = "ინტერფეისის ზომა"
-"pref.userName" = "თქვენი სახელი კომენტარებში"
+"pref.userName" = "თქვენი სახელი"
"proto.action.add" = "პროტოტიპის მოქმედების დამატება"
"proto.action.move" = "პროტოტიპის მოქმედების გადალაგება"
"proto.action.remove" = "პროტოტიპის მოქმედების წაშლა"
diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml
index 693cf5b6..cd256ac0 100644
--- a/crates/ui-egui/locales/ko.toml
+++ b/crates/ui-egui/locales/ko.toml
@@ -76,6 +76,7 @@ status = "draft — needs native review"
"Advanced stroke" = "고급 선"
"After delay" = "지연 후"
"After delay {ms}ms" = "{ms}ms 지연 후"
+"Agent" = "에이전트"
"Align" = "정렬"
"Align bottom" = "아래쪽 정렬"
"Align center" = "가운데 정렬"
@@ -735,6 +736,7 @@ status = "draft — needs native review"
"Main component deleted" = "메인 컴포넌트가 삭제됨"
"Main menu" = "메인 메뉴"
"Make slot" = "슬롯으로 만들기"
+"Slot name" = "슬롯 이름"
"Margin" = "여백"
"Mark all as read" = "모두 읽음으로 표시"
"Mark as resolved" = "해결됨으로 표시"
@@ -1017,7 +1019,6 @@ status = "draft — needs native review"
"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" = "밀어내기"
@@ -1165,6 +1166,7 @@ status = "draft — needs native review"
"Search assets" = "에셋 검색"
"Search comments" = "댓글 검색"
"Search fonts" = "글꼴 검색"
+"Use fonts installed on this computer…" = "이 컴퓨터에 설치된 글꼴 사용…"
"Search layers" = "레이어 검색"
"Search plugins" = "플러그인 검색"
"Search shortcuts" = "단축키 검색"
@@ -1207,7 +1209,7 @@ status = "draft — needs native review"
"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." = "파일을 공유하려면 데스크톱 앱이 필요함. 여기서는 참여만 가능."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "온라인 공유가 꺼져 있음: 환경설정에 서버가 지정되지 않음. 여기서는 공유된 파일에 참여만 가능."
"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}"
@@ -1359,6 +1361,7 @@ status = "draft — needs native review"
"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 page keeps the owner token only while it is open. Copy it to manage the file later: 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" = "이 파일에 저장되지 않은 변경 사항이 있음"
@@ -1425,6 +1428,7 @@ status = "draft — needs native review"
"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를 시도할 수 있습니다."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft에서 {apis}을(를) 시도한 뒤 저전력 GPU에서 요구 사항을 낮춰 다시 시도했습니다. 그래픽 드라이버를 업데이트하면 해결되는 경우가 많습니다."
"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" = "고정 값 사용"
@@ -1506,7 +1510,6 @@ status = "draft — needs native review"
"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"
"Zoom" = "확대/축소"
@@ -2147,7 +2150,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "복제한 레이어 이름 바꾸기"
"pref.showDimensions" = "개체에 치수 표시"
"pref.startOnHome" = "홈에서 시작"
-"pref.userName" = "댓글에 표시할 이름"
+"pref.userName" = "내 이름"
"proto.action.add" = "프로토타입 액션 추가"
"proto.action.move" = "프로토타입 액션 순서 변경"
"proto.action.remove" = "프로토타입 액션 제거"
diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml
index 63708f82..803ce53c 100644
--- a/crates/ui-egui/locales/nl.toml
+++ b/crates/ui-egui/locales/nl.toml
@@ -80,6 +80,7 @@ status = "draft — needs native review"
"Advanced stroke" = "Geavanceerde lijn"
"After delay" = "Na vertraging"
"After delay {ms}ms" = "Na {ms}ms vertraging"
+"Agent" = "Agent"
"Align" = "Uitlijnen"
"Align bottom" = "Onderaan uitlijnen"
"Align center" = "Midden uitlijnen"
@@ -777,6 +778,7 @@ status = "draft — needs native review"
"Main menu" = "Hoofdmenu"
"Make a component" = "Maak een component"
"Make slot" = "Slot maken"
+"Slot name" = "Slotnaam"
"Mar" = "mrt."
"Margin" = "Marge"
"Mark all as read" = "Markeer alles als gelezen"
@@ -1079,7 +1081,6 @@ status = "draft — needs native review"
"Published version {v} to the server" = "Versie {v} naar de server gepubliceerd"
"Published version {v}: save the file so other files can use it" = "Gepubliceerde versie {v}: sla het bestand op zodat andere bestanden het kunnen gebruiken"
"Published · {people}" = "Gepubliceerd · {people}"
-"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publiceren naar een server vraagt om de desktop-app; in de browser kun je deelnemen aan de bestanden van een server"
"Publishing with the server's key." = "Publiceren met de sleutel van de server."
"Publishing…" = "Bezig met publiceren…"
"Push" = "Pushen"
@@ -1228,6 +1229,7 @@ status = "draft — needs native review"
"Search comments" = "Zoeken in reacties"
"Search files and templates" = "Bestanden en sjablonen zoeken"
"Search fonts" = "Lettertypen zoeken"
+"Use fonts installed on this computer…" = "Lettertypen gebruiken die op deze computer zijn geïnstalleerd…"
"Search layers" = "Lagen doorzoeken"
"Search plugins" = "Zoeken naar plugins"
"Search results" = "Zoekresultaten"
@@ -1276,7 +1278,7 @@ status = "draft — needs native review"
"Share: live session, export" = "Delen: live-sessie, exporteren"
"Shared" = "Gedeeld"
"Shared: copy its code from the file's menu" = "Gedeeld: kopieer de code uit het menu van het bestand"
-"Sharing a file needs the desktop app; here you can join one." = "Een bestand delen vereist de desktop-app; hier kun je aan één deelnemen."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Online delen staat uit: er is geen server ingesteld in Voorkeuren. Hier kun je deelnemen aan een gedeeld bestand."
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Delen op het lokale netwerk (online delen staat uit: er is geen server ingesteld in Voorkeuren)."
"Sharing on your network · {people}" = "Delen via je netwerk · {people}"
"Sharing online · {people}" = "Delen online · {people}"
@@ -1441,6 +1443,7 @@ status = "draft — needs native review"
"This branch was merged or archived." = "Deze branch is samengevoegd of gearchiveerd."
"This computer only" = "Alleen op deze computer"
"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." = "Deze computer onthoudt het eigenaartoken, zodat het bestand beheerd kan worden vanuit de Online-lijst op het beginscherm. Kopieer het om het bestand vanaf een andere plek te beheren: het is het wachtwoord voor dit bestand en wordt niet opnieuw weergegeven."
+"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Deze pagina bewaart het eigenaartoken alleen zolang ze open is. Kopieer het om het bestand later te beheren: het is het wachtwoord voor dit bestand en wordt niet opnieuw weergegeven."
"This condition is edited as an expression" = "Deze voorwaarde wordt als expressie bewerkt"
"This file" = "Dit bestand"
"This file has unsaved changes" = "Dit bestand heeft niet-opgeslagen wijzigingen"
@@ -1509,6 +1512,7 @@ status = "draft — needs native review"
"Updated {name}" = "{name} bijgewerkt"
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Het bijwerken van de grafische driver helpt vaak. Om alleen OpenGL te gebruiken, start je FigCraft vanaf de Opdrachtprompt met:"
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Het bijwerken van de grafische driver helpt vaak. WGPU_BACKEND (vulkan, gl, metal) noemt een andere grafische API om te proberen."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft heeft {apis} geprobeerd en daarna opnieuw met lagere eisen op de energiezuinige GPU. Het bijwerken van de grafische driver helpt vaak."
"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." = "Uploadt dit bestand naar een FigCraft-server en houdt dit tabblad daarmee gesynchroniseerd. Je krijgt een link om te bewerken en een link om te bekijken."
"Uppercase" = "Hoofdletters"
"Use a fixed value" = "Gebruik een vaste waarde"
@@ -1593,7 +1597,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Je kopie van het bestand blijft open"
"Your copy stays open; the file stays on the server" = "Jouw kopie blijft open; het bestand blijft op de server"
"Your name" = "Jouw naam"
-"Your name in comments" = "Je naam in reacties"
"Your system asks for reduced motion: animations stay off." = "Jouw systeem vraagt om minder beweging: animaties blijven uit."
"Z" = "Z"
"Z to A" = "Z tot A"
@@ -2132,7 +2135,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "Gedupliceerde lagen hernoemen"
"pref.showDimensions" = "Afmetingen op objecten tonen"
"pref.startOnHome" = "Starten op Home"
-"pref.userName" = "Je naam in reacties"
+"pref.userName" = "Jouw naam"
"proto.action.add" = "Prototypeactie toevoegen"
"proto.action.move" = "Volgorde van prototypeactie wijzigen"
"proto.action.remove" = "Prototype-actie verwijderen"
diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml
index d5402774..0d4986cd 100644
--- a/crates/ui-egui/locales/pl.toml
+++ b/crates/ui-egui/locales/pl.toml
@@ -78,6 +78,7 @@ name = "Polski"
"Advanced stroke" = "Zaawansowany obrys"
"After delay" = "Po opóźnieniu"
"After delay {ms}ms" = "Po opóźnieniu {ms} ms"
+"Agent" = "Agent"
"Align" = "Wyrównaj"
"Align bottom" = "Wyrównaj do dołu"
"Align center" = "Wyrównaj do środka"
@@ -779,6 +780,7 @@ name = "Polski"
"Main menu" = "Menu główne"
"Make a component" = "Utwórz komponent"
"Make slot" = "Utwórz gniazdo (slot)"
+"Slot name" = "Nazwa gniazda"
"Mar" = "Mar"
"Margin" = "Margines"
"Mark all as read" = "Oznacz wszystkie jako przeczytane"
@@ -1081,7 +1083,6 @@ name = "Polski"
"Published version {v} to the server" = "Opublikowano wersję {v} na serwerze"
"Published version {v}: save the file so other files can use it" = "Opublikowano wersję {v}: zapisz plik, aby inne pliki mogły go używać"
"Published · {people}" = "Opublikowano · {people}"
-"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publikowanie na serwerze wymaga aplikacji desktopowej; w przeglądarce możesz dołączać do plików serwera"
"Publishing with the server's key." = "Publikowanie przy użyciu klucza serwera."
"Publishing…" = "Publikowanie…"
"Push" = "Wypychanie"
@@ -1238,6 +1239,7 @@ name = "Polski"
"Search files and templates" = "Szukaj plików i szablonów"
"Search results" = "Wyniki wyszukiwania"
"Search fonts" = "Szukaj czcionek"
+"Use fonts installed on this computer…" = "Użyj czcionek zainstalowanych na tym komputerze…"
"Search layers" = "Szukaj warstw"
"Search pages" = "Szukaj stron"
"Search shortcuts" = "Szukaj skrótów"
@@ -1282,7 +1284,7 @@ name = "Polski"
"Share: live session, export" = "Udostępnianie: sesja na żywo, eksport"
"Shared" = "Udostępniono"
"Shared: copy its code from the file's menu" = "Udostępniono: skopiuj kod z menu pliku"
-"Sharing a file needs the desktop app; here you can join one." = "Udostępnianie pliku wymaga aplikacji desktopowej; tutaj możesz tylko dołączyć."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Udostępnianie online wyłączone: brak serwera w Preferencjach. Tutaj możesz dołączyć do udostępnionego pliku."
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Udostępnianie w sieci lokalnej (udostępnianie online wyłączone: brak serwera w Preferencjach)."
"Sharing on your network · {people}" = "Udostępnianie w Twojej sieci · {people}"
"Sharing online · {people}" = "Udostępnianie online · {people}"
@@ -1448,6 +1450,7 @@ name = "Polski"
"This branch was merged or archived." = "Ta gałąź została scalona lub zarchiwizowana."
"This computer only" = "Tylko ten komputer"
"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." = "Ten komputer pamięta token właściciela, więc plik może być zarządzany z listy Online na ekranie głównym. Skopiuj go, aby zarządzać plikiem z innego miejsca: jest to hasło do tego pliku i nie zostanie ponownie wyświetlone."
+"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Ta strona przechowuje token właściciela tylko, dopóki jest otwarta. Skopiuj go, aby zarządzać plikiem później: jest to hasło do tego pliku i nie zostanie ponownie wyświetlone."
"This condition is edited as an expression" = "Ten warunek jest edytowany jako wyrażenie"
"This file" = "Ten plik"
"This file has unsaved changes" = "Ten plik ma niezapisane zmiany"
@@ -1518,6 +1521,7 @@ name = "Polski"
"Updated {name}" = "Zaktualizowano {name}"
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Aktualizacja sterownika graficznego często pomaga. Aby używać tylko OpenGL, uruchom FigCraft z wiersza poleceń za pomocą:"
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Aktualizacja sterownika graficznego często pomaga. Zmienna WGPU_BACKEND (vulkan, gl, metal) pozwala wybrać inne graficzne API do wypróbowania."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft wypróbował {apis}, a potem ponownie z niższymi wymaganiami na energooszczędnym GPU. Aktualizacja sterownika graficznego często pomaga."
"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." = "Przesyła ten plik na serwer FigCraft i synchronizuje tę kartę na bieżąco. Otrzymujesz link do edycji oraz link do podglądu."
"Uppercase" = "WIELKIE LITERY"
"Use a fixed value" = "Użyj stałej wartości"
@@ -1603,7 +1607,6 @@ name = "Polski"
"Your copy of the file stays open" = "Twoja kopia pliku pozostaje otwarta"
"Your copy stays open; the file stays on the server" = "Twoja kopia pozostaje otwarta; plik pozostaje na serwerze"
"Your name" = "Twoje imię i nazwisko"
-"Your name in comments" = "Twoje imię w komentarzach"
"Your system asks for reduced motion: animations stay off." = "Twój system preferuje ograniczony ruch: animacje pozostają wyłączone."
"Z" = "Z"
"Z to A" = "Od Z do A"
@@ -2146,7 +2149,7 @@ name = "Polski"
"pref.showDimensions" = "Pokazuj wymiary przy zaznaczeniu"
"pref.startOnHome" = "Rozpoczynaj od ekranu głównego"
"pref.uiScale" = "Rozmiar interfejsu"
-"pref.userName" = "Twoja nazwa w komentarzach"
+"pref.userName" = "Twoje imię i nazwisko"
"proto.action.add" = "Dodaj akcję prototypu"
"proto.action.move" = "Zmień kolejność akcji prototypu"
"proto.action.remove" = "Usuń akcję prototypu"
diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml
index 0f631556..8825d657 100644
--- a/crates/ui-egui/locales/pt-BR.toml
+++ b/crates/ui-egui/locales/pt-BR.toml
@@ -74,6 +74,7 @@ status = "draft — needs native review"
"Advanced stroke" = "Traço avançado"
"After delay" = "Após atraso"
"After delay {ms}ms" = "Após {ms} ms"
+"Agent" = "Agente"
"Align" = "Alinhamento"
"Align bottom" = "Alinhar embaixo"
"Align center" = "Alinhar ao centro"
@@ -734,6 +735,7 @@ status = "draft — needs native review"
"Main component deleted" = "Componente principal excluído"
"Main menu" = "Menu principal"
"Make slot" = "Transformar em slot"
+"Slot name" = "Nome do slot"
"Margin" = "Margem"
"Mark all as read" = "Marcar tudo como lido"
"Mark as resolved" = "Marcar como resolvido"
@@ -1016,7 +1018,6 @@ status = "draft — needs native review"
"Published version {v} to the server" = "Versão {v} publicada no servidor"
"Published version {v}: save the file so other files can use it" = "Versão {v} publicada: salve o arquivo para que outros arquivos possam usá-la"
"Published · {people}" = "Publicado · {people}"
-"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Para publicar em um servidor é preciso o app para desktop; no navegador você pode entrar em arquivos de um servidor"
"Publishing with the server's key." = "Publicando com a chave do servidor."
"Publishing…" = "Publicando…"
"Push" = "Empurrar"
@@ -1164,6 +1165,7 @@ status = "draft — needs native review"
"Search assets" = "Pesquisar recursos"
"Search comments" = "Pesquisar comentários"
"Search fonts" = "Pesquisar fontes"
+"Use fonts installed on this computer…" = "Usar as fontes instaladas neste computador…"
"Search layers" = "Pesquisar camadas"
"Search plugins" = "Pesquisar plugins"
"Search shortcuts" = "Pesquisar atalhos"
@@ -1206,7 +1208,7 @@ status = "draft — needs native review"
"Share: live session, export" = "Compartilhar: sessão ao vivo, exportar"
"Shared" = "Compartilhado"
"Shared: copy its code from the file's menu" = "Compartilhado: copie o código pelo menu do arquivo"
-"Sharing a file needs the desktop app; here you can join one." = "Compartilhar um arquivo requer o aplicativo para desktop; aqui você pode entrar em um."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "O compartilhamento online está desativado: não há servidor definido nas Preferências. Aqui você pode entrar em um arquivo compartilhado."
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Compartilhando na rede local (o compartilhamento online está desativado: não há servidor definido nas Preferências)."
"Sharing on your network · {people}" = "Compartilhando na sua rede · {people}"
"Sharing online · {people}" = "Compartilhando online · {people}"
@@ -1357,6 +1359,7 @@ status = "draft — needs native review"
"This branch was merged or archived." = "Esta branch foi mesclada ou arquivada."
"This computer only" = "Somente este computador"
"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." = "Este computador guarda o token de proprietário, então o arquivo pode ser gerenciado pela lista Online da tela inicial. Copie-o para gerenciar o arquivo de outro lugar: ele é uma senha para este arquivo e não será mostrado de novo."
+"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Esta página guarda o token de proprietário só enquanto estiver aberta. Copie-o para gerenciar o arquivo depois: ele é uma senha para este arquivo e não será mostrado de novo."
"This condition is edited as an expression" = "Esta condição é editada como expressão"
"This file" = "Este arquivo"
"This file has unsaved changes" = "Este arquivo tem alterações não salvas"
@@ -1423,6 +1426,7 @@ status = "draft — needs native review"
"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."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "O FigCraft tentou {apis} e depois de novo com requisitos menores na GPU de baixo consumo. Atualizar o driver de vídeo costuma ajudar."
"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"
@@ -1504,7 +1508,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Sua cópia do arquivo continua aberta"
"Your copy stays open; the file stays on the server" = "Sua cópia continua aberta; o arquivo continua no servidor"
"Your name" = "Seu nome"
-"Your name in comments" = "Seu nome nos comentários"
"Your system asks for reduced motion: animations stay off." = "Seu sistema pede movimento reduzido: as animações ficam desativadas."
"Z" = "Z"
"Zoom" = "Zoom"
@@ -2144,7 +2147,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "Renomear camadas duplicadas"
"pref.showDimensions" = "Mostrar dimensões nos objetos"
"pref.startOnHome" = "Começar no Início"
-"pref.userName" = "Seu nome nos comentários"
+"pref.userName" = "Seu nome"
"proto.action.add" = "Adicionar ação de protótipo"
"proto.action.move" = "Reordenar ação de protótipo"
"proto.action.remove" = "Remover ação de protótipo"
diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml
index 6e6932d5..0065e930 100644
--- a/crates/ui-egui/locales/ru.toml
+++ b/crates/ui-egui/locales/ru.toml
@@ -78,6 +78,7 @@ name = "Русский"
"Advanced stroke" = "Расширенные настройки обводки"
"After delay" = "После задержки"
"After delay {ms}ms" = "После задержки {ms} мс"
+"Agent" = "Агент"
"Align" = "Выравнивание"
"Align bottom" = "Выровнять по нижнему краю"
"Align center" = "Выровнять по центру"
@@ -779,6 +780,7 @@ name = "Русский"
"Main menu" = "Главное меню"
"Make a component" = "Создать компонент"
"Make slot" = "Сделать слотом"
+"Slot name" = "Имя слота"
"Mar" = "мар."
"Margin" = "Внешний отступ"
"Mark all as read" = "Отметить всё как прочитанное"
@@ -1081,7 +1083,6 @@ name = "Русский"
"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" = "Вытеснение"
@@ -1239,6 +1240,7 @@ name = "Русский"
"Search files and templates" = "Поиск файлов и шаблонов"
"Search results" = "Результаты поиска"
"Search fonts" = "Поиск шрифтов"
+"Use fonts installed on this computer…" = "Использовать шрифты, установленные на этом компьютере…"
"Search layers" = "Поиск слоёв"
"Search shortcuts" = "Поиск сочетаний клавиш"
"Section" = "Секция"
@@ -1282,7 +1284,7 @@ name = "Русский"
"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." = "Для публикации файла нужно настольное приложение; здесь можно подключиться к опубликованному файлу."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Онлайн-публикация отключена: сервер не задан в настройках. Здесь можно подключиться к опубликованному файлу."
"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}"
@@ -1448,6 +1450,7 @@ name = "Русский"
"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 page keeps the owner token only while it is open. Copy it to manage the file later: 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" = "В файле есть несохранённые изменения"
@@ -1518,6 +1521,7 @@ name = "Русский"
"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, который можно попробовать."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft попробовал {apis}, а затем ещё раз с пониженными требованиями на энергосберегающем GPU. Часто помогает обновление драйвера видеокарты."
"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" = "Использовать фиксированное значение"
@@ -1603,7 +1607,6 @@ name = "Русский"
"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" = "От Я до А"
@@ -2146,7 +2149,7 @@ name = "Русский"
"pref.showDimensions" = "Показать размеры объектов"
"pref.startOnHome" = "Начинать с главной"
"pref.uiScale" = "Размер интерфейса"
-"pref.userName" = "Ваше имя в комментариях"
+"pref.userName" = "Ваше имя"
"proto.action.add" = "Добавить действие прототипа"
"proto.action.move" = "Изменить порядок действия прототипа"
"proto.action.remove" = "Удалить действие прототипа"
diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml
index 01b3d7b9..714b1ecf 100644
--- a/crates/ui-egui/locales/sv.toml
+++ b/crates/ui-egui/locales/sv.toml
@@ -78,6 +78,7 @@ name = "Svenska"
"Advanced stroke" = "Avancerad kontur"
"After delay" = "Efter fördröjning"
"After delay {ms}ms" = "Efter fördröjning på {ms} ms"
+"Agent" = "Agent"
"Align" = "Justera"
"Align bottom" = "Justera nederkant"
"Align center" = "Centrera"
@@ -801,6 +802,7 @@ name = "Svenska"
"Main menu" = "Huvudmeny"
"Make a component" = "Skapa en komponent"
"Make slot" = "Gör till plats"
+"Slot name" = "Platsens namn"
"Mar" = "Mar"
"Margin" = "Marginal"
"Mark all as read" = "Märk alla som lästa"
@@ -1107,7 +1109,6 @@ name = "Svenska"
"Published version {v} to the server" = "Version {v} publicerad på servern"
"Published version {v}: save the file so other files can use it" = "Version {v} publicerad: spara filen så att andra filer kan använda den"
"Published · {people}" = "Publicerad · {people}"
-"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publicering på en server kräver skrivbordsappen; i webbläsaren kan du ansluta till en servers filer"
"Publishing with the server's key." = "Publicerar med serverns nyckel."
"Publishing…" = "Publicerar…"
"Push" = "Skjut"
@@ -1265,6 +1266,7 @@ name = "Svenska"
"Search files and templates" = "Sök efter filer och mallar"
"Search results" = "Sökresultat"
"Search fonts" = "Sök typsnitt"
+"Use fonts installed on this computer…" = "Använd typsnitt som är installerade på den här datorn…"
"Search layers" = "Sök lager"
"Search pages" = "Sök sidor"
"Search shortcuts" = "Sök kortkommandon"
@@ -1311,7 +1313,7 @@ name = "Svenska"
"Share: live session, export" = "Dela: livesession, export"
"Shared" = "Delad"
"Shared: copy its code from the file's menu" = "Delad: kopiera koden från filens meny"
-"Sharing a file needs the desktop app; here you can join one." = "Att dela en fil kräver skrivbordsappen; här kan du ansluta till en."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Delning online är av: ingen server har angetts i Inställningar. Här kan du ansluta till en delad fil."
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Delar i det lokala nätverket (delning online är av: ingen server har angetts i Inställningar)."
"Sharing on your network · {people}" = "Delar i ditt nätverk · {people}"
"Sharing online · {people}" = "Delar online · {people}"
@@ -1478,6 +1480,7 @@ name = "Svenska"
"This branch was merged or archived." = "Den här grenen har sammanfogats eller arkiverats."
"This computer only" = "Endast den här datorn"
"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." = "Den här datorn kommer ihåg ägartoken, så filen kan hanteras från startskärmens onlinelista. Kopiera den om du vill hantera filen från en annan plats: den fungerar som ett lösenord för filen och visas inte igen."
+"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Den här sidan behåller ägartoken bara så länge den är öppen. Kopiera den om du vill hantera filen senare: den fungerar som ett lösenord för filen och visas inte igen."
"This condition is edited as an expression" = "Det här villkoret redigeras som ett uttryck"
"This file" = "Den här filen"
"This file has unsaved changes" = "Filen har osparade ändringar"
@@ -1552,6 +1555,7 @@ name = "Svenska"
"Updated {name}" = "{name} har uppdaterats"
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Det hjälper ofta att uppdatera grafikdrivrutinen. Om du bara vill använda OpenGL startar du FigCraft från en kommandotolk med:"
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Det hjälper ofta att uppdatera grafikdrivrutinen. WGPU_BACKEND (vulkan, gl, metal) anger ett annat grafik-API att prova."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft provade {apis} och sedan igen med lägre krav på den strömsnåla grafikprocessorn. Det hjälper ofta att uppdatera grafikdrivrutinen."
"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." = "Laddar upp filen till en FigCraft-server och håller fliken synkroniserad med den. Du får en länk för redigering och en för visning."
"Uppercase" = "Versaler"
"Use a fixed value" = "Använd ett fast värde"
@@ -1639,7 +1643,6 @@ name = "Svenska"
"Your copy of the file stays open" = "Din kopia av filen förblir öppen"
"Your copy stays open; the file stays on the server" = "Din kopia förblir öppen; filen ligger kvar på servern"
"Your name" = "Ditt namn"
-"Your name in comments" = "Ditt namn i kommentarer"
"Your system asks for reduced motion: animations stay off." = "Systemet begär minskad rörelse: animeringar förblir avstängda."
"Z" = "Z"
"Z to A" = "Ö till A"
@@ -2191,7 +2194,7 @@ name = "Svenska"
"pref.showDimensions" = "Visa dimensioner på objekt"
"pref.startOnHome" = "Börja på Start"
"pref.uiScale" = "Gränssnittsstorlek"
-"pref.userName" = "Ditt namn i kommentarer"
+"pref.userName" = "Ditt namn"
"proto.action.add" = "Lägg till prototypåtgärd"
"proto.action.move" = "Ändra ordning på prototypåtgärd"
"proto.action.remove" = "Ta bort prototypåtgärd"
diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml
index dc4ea922..730fb297 100644
--- a/crates/ui-egui/locales/th.toml
+++ b/crates/ui-egui/locales/th.toml
@@ -80,6 +80,7 @@ status = "draft — needs native review"
"Advanced stroke" = "เส้นขอบขั้นสูง"
"After delay" = "หลังหน่วงเวลา"
"After delay {ms}ms" = "หลังหน่วงเวลา {ms} มิลลิวินาที"
+"Agent" = "เอเจนต์"
"Align" = "จัดแนว"
"Align bottom" = "จัดแนวชิดล่าง"
"Align center" = "จัดแนวกึ่งกลาง"
@@ -773,6 +774,7 @@ status = "draft — needs native review"
"Main menu" = "เมนูหลัก"
"Make a component" = "สร้างคอมโพเนนต์"
"Make slot" = "สร้างสล็อต"
+"Slot name" = "ชื่อสล็อต"
"Mar" = "มี.ค."
"Margin" = "ระยะขอบนอก"
"Mark all as read" = "ทำเครื่องหมายว่าอ่านทั้งหมดแล้ว"
@@ -1072,7 +1074,6 @@ status = "draft — needs native review"
"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" = "ผลัก"
@@ -1223,6 +1224,7 @@ status = "draft — needs native review"
"Search files and templates" = "ค้นหาไฟล์และเทมเพลต"
"Search results" = "ผลการค้นหา"
"Search fonts" = "ค้นหาฟอนต์"
+"Use fonts installed on this computer…" = "ใช้ฟอนต์ที่ติดตั้งในคอมพิวเตอร์เครื่องนี้…"
"Search layers" = "ค้นหาเลเยอร์"
"Search pages" = "ค้นหาหน้า"
"Search shortcuts" = "ค้นหาแป้นพิมพ์ลัด"
@@ -1267,7 +1269,7 @@ status = "draft — needs native review"
"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." = "การแชร์ไฟล์ต้องใช้แอปเดสก์ท็อป ที่นี่เข้าร่วมได้อย่างเดียว"
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "การแชร์ออนไลน์ปิดอยู่: ไม่ได้ตั้งเซิร์ฟเวอร์ในการตั้งค่า ที่นี่เข้าร่วมไฟล์ที่แชร์ได้อย่างเดียว"
"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}"
@@ -1430,6 +1432,7 @@ status = "draft — needs native review"
"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 page keeps the owner token only while it is open. Copy it to manage the file later: 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" = "ไฟล์นี้มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก"
@@ -1500,6 +1503,7 @@ status = "draft — needs native review"
"Updated {name}" = "อัปเดต {name} แล้ว"
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "การอัปเดตไดรเวอร์กราฟิกมักช่วยได้ หากต้องการใช้ OpenGL เท่านั้น ให้เริ่ม FigCraft จาก Command Prompt ด้วย:"
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "การอัปเดตไดรเวอร์กราฟิกมักช่วยได้ WGPU_BACKEND (vulkan, gl, metal) ระบุ API กราฟิกอื่นที่ลองใช้ได้"
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft ลอง {apis} แล้ว จากนั้นลองอีกครั้งด้วยข้อกำหนดที่ต่ำลงบน GPU แบบประหยัดพลังงาน การอัปเดตไดรเวอร์กราฟิกมักช่วยได้"
"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" = "ใช้ค่าคงที่"
@@ -1583,7 +1587,6 @@ status = "draft — needs native review"
"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" = "Z ถึง A"
@@ -2109,7 +2112,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "เปลี่ยนชื่อเลเยอร์ที่ทำสำเนา"
"pref.showDimensions" = "แสดงขนาดบนออบเจกต์"
"pref.startOnHome" = "เริ่มที่หน้าจอหลัก"
-"pref.userName" = "ชื่อของคุณในความคิดเห็น"
+"pref.userName" = "ชื่อของคุณ"
"proto.action.add" = "เพิ่มการกระทำโปรโตไทป์"
"proto.action.move" = "เรียงลำดับการกระทำโปรโตไทป์ใหม่"
"proto.action.remove" = "นำการกระทำโปรโตไทป์ออก"
diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml
index 58a0f4e3..dbb75d4c 100644
--- a/crates/ui-egui/locales/tr.toml
+++ b/crates/ui-egui/locales/tr.toml
@@ -78,6 +78,7 @@ name = "Türkçe"
"Advanced stroke" = "Gelişmiş kontur"
"After delay" = "Gecikmeden sonra"
"After delay {ms}ms" = "{ms} ms gecikmeden sonra"
+"Agent" = "Ajan"
"Align" = "Hizala"
"Align bottom" = "Alta hizala"
"Align center" = "Ortaya hizala"
@@ -764,6 +765,7 @@ name = "Türkçe"
"Main menu" = "Ana menü"
"Make a component" = "Bileşen yap"
"Make slot" = "Yuva yap"
+"Slot name" = "Yuva adı"
"Mar" = "Mar"
"Margin" = "Kenar boşluğu"
"Mark all as read" = "Tümünü okundu olarak işaretle"
@@ -1058,7 +1060,6 @@ name = "Türkçe"
"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"
@@ -1208,6 +1209,7 @@ name = "Türkçe"
"Search assets" = "Varlıklarda ara"
"Search comments" = "Yorumlarda ara"
"Search fonts" = "Yazı tiplerinde ara"
+"Use fonts installed on this computer…" = "Bu bilgisayarda yüklü yazı tiplerini kullan…"
"Search layers" = "Katmanlarda ara"
"Search shortcuts" = "Kısayollarda ara"
"Section" = "Bölüm"
@@ -1251,7 +1253,7 @@ name = "Türkçe"
"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."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Çevrimiçi paylaşım kapalı: Tercihler'de sunucu ayarlanmamış. Burada paylaşılan bir dosyaya 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}"
@@ -1412,6 +1414,7 @@ name = "Türkç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 page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Bu sayfa sahip belirtecini yalnızca açık kaldığı sürece tutar. Dosyayı daha sonra 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"
@@ -1558,7 +1561,6 @@ name = "Türkçe"
"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"
@@ -2091,7 +2093,7 @@ name = "Türkçe"
"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"
+"pref.userName" = "Adınız"
"proto.action.add" = "Prototip eylemi ekle"
"proto.action.move" = "Prototip eylemini yeniden sırala"
"proto.action.remove" = "Prototip eylemini kaldır"
diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml
index 19253912..384f829e 100644
--- a/crates/ui-egui/locales/vi.toml
+++ b/crates/ui-egui/locales/vi.toml
@@ -75,6 +75,7 @@ status = "draft — needs native review"
"Advanced stroke" = "Nét nâng cao"
"After delay" = "Sau khoảng trễ"
"After delay {ms}ms" = "Sau khoảng trễ {ms}ms"
+"Agent" = "Tác nhân"
"Align" = "Căn chỉnh"
"Align bottom" = "Căn dưới"
"Align center" = "Căn giữa"
@@ -751,6 +752,7 @@ status = "draft — needs native review"
"Main menu" = "Trình đơn chính"
"Make a component" = "Tạo thành phần"
"Make slot" = "Tạo ô chứa"
+"Slot name" = "Tên ô chứa"
"Mar" = "Thg 3"
"Margin" = "Lề"
"Mark all as read" = "Đánh dấu tất cả là đã đọc"
@@ -1043,7 +1045,6 @@ status = "draft — needs native review"
"Published version {v} to the server" = "Đã xuất bản phiên bản {v} lên máy chủ"
"Published version {v}: save the file so other files can use it" = "Đã xuất bản phiên bản {v}: hãy lưu tệp để các tệp khác có thể sử dụng"
"Published · {people}" = "Đã xuất bản · {people}"
-"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Cần ứng dụng máy tính để xuất bản lên máy chủ; trên trình duyệt bạn có thể tham gia các tệp trên máy chủ"
"Publishing with the server's key." = "Đang xuất bản bằng khóa của máy chủ."
"Publishing…" = "Đang xuất bản…"
"Push" = "Đẩy"
@@ -1185,6 +1186,7 @@ status = "draft — needs native review"
"Search comments" = "Tìm bình luận"
"Search files and templates" = "Tìm tệp và mẫu"
"Search fonts" = "Tìm phông chữ"
+"Use fonts installed on this computer…" = "Dùng phông chữ đã cài trên máy tính này…"
"Search layers" = "Tìm lớp"
"Search pages" = "Tìm trang"
"Search plugins" = "Tìm plugin"
@@ -1233,7 +1235,7 @@ status = "draft — needs native review"
"Share: live session, export" = "Chia sẻ: phiên trực tiếp, xuất tệp"
"Shared" = "Đã chia sẻ"
"Shared: copy its code from the file's menu" = "Đã chia sẻ: sao chép mã từ trình đơn của tệp"
-"Sharing a file needs the desktop app; here you can join one." = "Cần ứng dụng máy tính để chia sẻ tệp; tại đây bạn có thể tham gia một phiên."
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Chia sẻ trực tuyến đang tắt: chưa đặt máy chủ trong Tùy chọn. Tại đây bạn có thể tham gia một tệp được chia sẻ."
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Đang chia sẻ trên mạng cục bộ (chia sẻ trực tuyến đang tắt: chưa đặt máy chủ trong Tùy chọn)."
"Sharing on your network · {people}" = "Đang chia sẻ trên mạng của bạn · {people}"
"Sharing online · {people}" = "Đang chia sẻ trực tuyến · {people}"
@@ -1388,6 +1390,7 @@ status = "draft — needs native review"
"This branch was merged or archived." = "Nhánh này đã được hợp nhất hoặc lưu trữ."
"This computer only" = "Chỉ máy tính này"
"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." = "Máy tính này ghi nhớ mã thông báo chủ sở hữu để bạn có thể quản lý tệp từ danh sách Trực tuyến ở màn hình Trang chủ. Hãy sao chép mã này nếu muốn quản lý tệp từ nơi khác: đây là mật khẩu của tệp và sẽ không hiển thị lại."
+"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Trang này chỉ giữ mã thông báo chủ sở hữu khi còn mở. Hãy sao chép mã này để quản lý tệp sau: đây là mật khẩu của tệp và sẽ không hiển thị lại."
"This condition is edited as an expression" = "Điều kiện này được chỉnh sửa dưới dạng biểu thức"
"This file" = "Tệp này"
"This file has unsaved changes" = "Tệp này có thay đổi chưa lưu"
@@ -1452,6 +1455,7 @@ status = "draft — needs native review"
"Updated {name}" = "Đã cập nhật {name}"
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Việc cập nhật trình điều khiển đồ họa thường sẽ khắc phục được lỗi. Để chỉ sử dụng OpenGL, hãy khởi chạy FigCraft từ Command Prompt bằng:"
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Việc cập nhật trình điều khiển đồ họa thường sẽ khắc phục được lỗi. Biến WGPU_BACKEND (vulkan, gl, metal) cho phép thử một API đồ họa khác."
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft đã thử {apis}, rồi thử lại với yêu cầu thấp hơn trên GPU tiết kiệm điện. Việc cập nhật trình điều khiển đồ họa thường sẽ khắc phục được lỗi."
"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." = "Tải tệp này lên máy chủ FigCraft và giữ thẻ này đồng bộ với máy chủ. Bạn sẽ nhận được một liên kết chỉnh sửa và một liên kết xem."
"Uppercase" = "Chữ hoa"
"Use a fixed value" = "Dùng giá trị cố định"
@@ -1533,7 +1537,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Bản sao tệp của bạn vẫn mở"
"Your copy stays open; the file stays on the server" = "Bản sao của bạn vẫn mở; tệp vẫn được lưu trên máy chủ"
"Your name" = "Tên của bạn"
-"Your name in comments" = "Tên của bạn trong bình luận"
"Your system asks for reduced motion: animations stay off." = "Hệ thống của bạn yêu cầu giảm chuyển động: các hoạt ảnh sẽ được tắt."
"Z" = "Z"
"Z to A" = "Z đến A"
@@ -2049,7 +2052,7 @@ status = "draft — needs native review"
"pref.showDimensions" = "Hiện kích thước trên đối tượng"
"pref.startOnHome" = "Khởi động tại màn hình Trang chủ"
"pref.uiScale" = "Kích thước giao diện"
-"pref.userName" = "Tên của bạn trong bình luận"
+"pref.userName" = "Tên của bạn"
"proto.action.add" = "Thêm hành động nguyên mẫu"
"proto.action.move" = "Sắp xếp lại hành động nguyên mẫu"
"proto.action.remove" = "Xóa hành động nguyên mẫu"
diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml
index ce5056b9..6953ce1c 100644
--- a/crates/ui-egui/locales/zh-Hans.toml
+++ b/crates/ui-egui/locales/zh-Hans.toml
@@ -77,6 +77,7 @@ status = "draft — needs native review"
"Advanced stroke" = "高级描边"
"After delay" = "延迟后"
"After delay {ms}ms" = "延迟 {ms} 毫秒后"
+"Agent" = "智能体"
"Align" = "对齐"
"Align bottom" = "底部对齐"
"Align center" = "居中对齐"
@@ -740,6 +741,7 @@ status = "draft — needs native review"
"Main component deleted" = "主组件已删除"
"Main menu" = "主菜单"
"Make slot" = "设为插槽"
+"Slot name" = "插槽名称"
"Margin" = "边距"
"Mark all as read" = "全部标为已读"
"Mark as resolved" = "标为已解决"
@@ -1025,7 +1027,6 @@ status = "draft — needs native review"
"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" = "推入"
@@ -1173,6 +1174,7 @@ status = "draft — needs native review"
"Search assets" = "搜索资源"
"Search comments" = "搜索评论"
"Search fonts" = "搜索字体"
+"Use fonts installed on this computer…" = "使用这台电脑上安装的字体…"
"Search layers" = "搜索图层"
"Search plugins" = "搜索插件"
"Search shortcuts" = "搜索快捷键"
@@ -1215,7 +1217,7 @@ status = "draft — needs native review"
"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." = "分享文件需要桌面应用;在这里你可以加入他人分享的文件。"
+"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "在线分享已关闭:偏好设置中未指定服务器。在这里你可以加入他人分享的文件。"
"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}"
@@ -1367,6 +1369,7 @@ status = "draft — needs native review"
"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 page keeps the owner token only while it is open. Copy it to manage the file later: 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" = "此文件有未保存的更改"
@@ -1433,6 +1436,7 @@ status = "draft — needs native review"
"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 来尝试。"
+"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft 已尝试 {apis},然后在省电 GPU 上以较低要求再次尝试。更新显卡驱动通常会有帮助。"
"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" = "使用固定值"
@@ -1514,7 +1518,6 @@ status = "draft — needs native review"
"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"
"Zoom" = "缩放"
@@ -2044,7 +2047,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "重命名副本图层"
"pref.showDimensions" = "在对象上显示尺寸"
"pref.startOnHome" = "启动时显示主页"
-"pref.userName" = "评论中显示的名字"
+"pref.userName" = "你的名字"
"proto.action.add" = "添加原型操作"
"proto.action.move" = "调整原型操作顺序"
"proto.action.remove" = "移除原型操作"
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
index 44a17658..fcd1b788 100644
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -305,6 +305,7 @@ impl FigApp {
layers::left_panel(self, ui)
});
}
+ self.ui.right_panel_rect = None;
if right_open {
if !narrow {
edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0));
diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs
index 7a27aff3..128cd029 100644
--- a/crates/ui-egui/src/app/frame.rs
+++ b/crates/ui-egui/src/app/frame.rs
@@ -34,11 +34,41 @@ impl FigApp {
/// Clicking the control that opened the colour picker closes it again; clicking another opens
/// the picker for that one.
- pub fn toggle_picker(&mut self, target: crate::colorpicker::PickerTarget, _top: Option) {
+ pub fn toggle_picker(&mut self, target: crate::colorpicker::PickerTarget, button: Option) {
if self.ui.picker.as_ref() == Some(&target) {
crate::colorpicker::close(self);
} else {
- self.ui.picker = Some(target);
+ self.open_picker(target, button);
+ }
+ }
+
+ /// Start a fresh picker beside its control, committing edits to the previous target first.
+ pub fn open_picker(&mut self, target: crate::colorpicker::PickerTarget, button: Option) {
+ if self.ui.picker.is_some() {
+ crate::colorpicker::close(self);
+ }
+ self.ui.picker_popover = None;
+ self.ui.picker_button_id = None;
+ self.ui.picker = Some(target);
+ self.ui.picker_button = button;
+ }
+
+ /// Follow only the widget that opened this target, even when several controls edit it.
+ pub fn refresh_picker_control(&mut self, target: &crate::colorpicker::PickerTarget, response: &egui::Response) {
+ if self.ui.picker.as_ref() == Some(target) && self.ui.picker_button_id.is_none_or(|id| id == response.id) {
+ self.ui.picker_button = response.interact_rect.is_positive().then_some(response.rect);
+ self.ui.picker_button_id = Some(response.id);
+ }
+ }
+
+ /// Drawn colour controls refresh their held rectangle and toggle the picker on a click.
+ pub fn picker_control(&mut self, target: crate::colorpicker::PickerTarget, response: &egui::Response) {
+ self.refresh_picker_control(&target, response);
+ if response.clicked() {
+ self.toggle_picker(target, Some(response.rect));
+ if self.ui.picker.is_some() {
+ self.ui.picker_button_id = Some(response.id);
+ }
}
}
diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs
index e4758ca7..7926dfd2 100644
--- a/crates/ui-egui/src/board_ui_tests.rs
+++ b/crates/ui-egui/src/board_ui_tests.rs
@@ -242,7 +242,7 @@ fn a_board_page_has_a_dot_grid_under_its_layers_thinned_out_zoomed_out() {
let fill = u.app.session.doc().unwrap().node(sticky).unwrap().fills[0].solid_color().unwrap().to_rgba8();
assert_eq!(at(2.0 * step, 2.0 * step), [fill[0], fill[1], fill[2]], "the sticky covers the dot under it");
// Zoomed out, the dots space out: every second, fourth… stays, the next to go fading.
- use crate::board_bar::dot_spacing;
+ let dot_spacing = |z: f64| crate::board_bar::grid_spacing(z, 24.0, 2, 14.0);
assert_eq!(dot_spacing(1.0), Some((24.0, ((24.0 - 14.0) / 14.0) as f32)));
assert_eq!(dot_spacing(0.25).map(|d| d.0), Some(96.0));
assert_eq!(dot_spacing(4.0), Some((24.0, 1.0)));
@@ -271,3 +271,69 @@ fn a_selected_connector_shows_its_ends_as_handles() {
assert!(ring, "a handle at {end:?}");
}
}
+
+#[test]
+fn the_board_grid_follows_its_settings() {
+ let mut u = board();
+ u.app.session.execute("view.zoom100", &json!({})).unwrap();
+ u.frame(vec![]);
+ let t = crate::theme::Tokens::get(&u.ctx);
+ let bg = egui::Color32::from_rgb(240, 240, 240);
+ let usual = crate::board_bar::dots(&u.app, bg, &t).unwrap();
+ assert!(!usual.lines && usual.major == 2);
+ // Lines, as fine at any zoom.
+ u.app.run("board.grid", json!({"style": "lines"}));
+ let lines = crate::board_bar::dots(&u.app, bg, &t).unwrap();
+ assert!(lines.lines && lines.major == 2 && lines.color.a < usual.color.a, "lines start fainter than dots");
+ // Graph paper: a strong line every 5 cells of 8, the cells in between fainter even zoomed in.
+ u.app.run("board.grid", json!({"style": "graph", "spacing": 8, "major": 5}));
+ u.app.session.ds_mut().unwrap().view.zoom = 4.0;
+ let graph = crate::board_bar::dots(&u.app, bg, &t).unwrap();
+ assert_eq!((graph.lines, graph.major, graph.step), (true, 5, 8.0));
+ assert!(graph.faded.a < graph.color.a);
+ // Zoomed out, the fine cells go and the strong lines stay: the step grows by fives.
+ u.app.session.ds_mut().unwrap().view.zoom = 0.25;
+ let far = crate::board_bar::dots(&u.app, bg, &t).unwrap();
+ assert_eq!(far.step, 8.0 * 5.0, "40 × 0.25 = 10 points apart, the closest lines come");
+ assert_eq!(far.faded.a, 0.0, "the cells the next step drops have faded out");
+ // More contrast: stronger marks; larger: larger.
+ u.app.run("board.grid", json!({"style": "dots", "contrast": 3, "size": 2}));
+ let strong = crate::board_bar::dots(&u.app, bg, &t).unwrap();
+ assert!(strong.color.a > usual.color.a * 2.5 && strong.size > usual.size * 0.25 * 1.5, "{strong:?}");
+ // None: no grid.
+ u.app.run("board.grid", json!({"style": "none"}));
+ assert!(crate::board_bar::dots(&u.app, bg, &t).is_none());
+ use crate::board_bar::grid_spacing;
+ assert_eq!(grid_spacing(1.0, 24.0, 5, 0.0), None);
+ assert_eq!(grid_spacing(1e-320, 512.0, 2, 14.0), None, "no finite step that far out");
+ assert!(grid_spacing(1e-300, 4.0, 12, 10.0).is_some_and(|(step, _)| step.is_finite()));
+}
+
+#[test]
+fn preferences_set_the_board_grid_and_the_default_dots_keep_their_size() {
+ let mut u = board();
+ let bg = egui::Color32::from_rgb(240, 240, 240);
+ // The default grid: dots a point and a half across at 100 %, one to three points on screen.
+ for (zoom, on_screen) in [(1.0, 1.5), (0.25, 1.0), (4.0, 3.0)] {
+ u.app.session.ds_mut().unwrap().view.zoom = zoom;
+ let t = crate::theme::Tokens::get(&u.ctx);
+ let size = crate::board_bar::dots(&u.app, bg, &t).unwrap().size;
+ assert!((size * zoom - on_screen).abs() < 1e-9, "at {zoom}: {size}");
+ }
+ u.app.session.ds_mut().unwrap().view.zoom = 1.0;
+ u.app.ui.preferences = true;
+ u.frame(vec![]);
+ let shapes = flat(&u.frame_output(vec![]).shapes);
+ assert!(has_text(&shapes, "Board grid") && has_text(&shapes, "Spacing"));
+ assert!(!has_text(&shapes, "Strong line every") && !has_text(&shapes, "Reset grid"), "dots, as usual");
+ click(&mut u, text_center(&shapes, "Graph paper"));
+ assert_eq!(u.app.session.prefs.board_grid.style, figcraft_engine::board_grid::GridStyle::Graph);
+ let shapes = flat(&u.frame_output(vec![]).shapes);
+ assert!(has_text(&shapes, "Strong line every"), "graph paper's cells per strong line");
+ click(&mut u, text_center(&shapes, "Reset grid"));
+ assert_eq!(u.app.session.prefs.board_grid, Default::default());
+ // No grid: nothing to set but the style.
+ u.app.run("board.grid", json!({"style": "none"}));
+ let shapes = flat(&u.frame_output(vec![]).shapes);
+ assert!(has_text(&shapes, "Board grid") && !has_text(&shapes, "Spacing"));
+}
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index a7e41c34..3faa779f 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -298,6 +298,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
crate::comments::draw(app, &painter);
crate::live_text::draw(app, &painter);
crate::live::draw(app, &painter, &ctx);
+ crate::live::agent::draw(app, &painter, &ctx);
crate::live::chat_box(app, &ctx);
crate::live::spotlight_banner(app, &ctx, rect);
crate::comments::popovers(app, &ctx);
@@ -363,5 +364,8 @@ fn describe_box([x0, y0, x1, y1]: [f64; 4]) -> String {
#[cfg(test)]
mod builder_poly_tests;
+#[cfg(test)]
+mod outline_tests;
+
#[cfg(test)]
mod zoom_preview_tests;
diff --git a/crates/ui-egui/src/canvas/board_bar.rs b/crates/ui-egui/src/canvas/board_bar.rs
index c1a76a4d..8a33538c 100644
--- a/crates/ui-egui/src/canvas/board_bar.rs
+++ b/crates/ui-egui/src/canvas/board_bar.rs
@@ -5,6 +5,7 @@
use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_doc::{BOARD_COLORS, BoardItem, BoardShape, NodeKind, Routing, Stamp, StrokeCap};
use figcraft_engine::board::{BoardTool, TEXT_SIZES};
+use figcraft_engine::board_grid::GridStyle;
use serde_json::{Value, json};
use crate::FigApp;
@@ -287,46 +288,123 @@ pub(crate) fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) -> f32 {
x + 8.0
}
-/// The dot grid's spacing on the page at 100%, and the closest its dots come on screen (points):
-/// zoomed out, every second, fourth… dot stays, the ones going fading out first.
-const DOT_STEP: f64 = 24.0;
+/// The closest the grid's marks come on screen (points): zoomed out, every second, fourth… mark
+/// stays (graph paper: every strong line), the ones going fading out first. Lines read at a
+/// smaller gap than dots.
const DOT_MIN_GAP: f64 = 14.0;
+const LINE_MIN_GAP: f64 = 10.0;
-/// A board page's dot grid on its background `bg`, for the canvas render to draw under the
-/// layers: dark dots on a light page, light ones on a dark page, in the theme's ink, about a point
-/// and a half across on screen. `None` on design files.
+/// A board page's grid on its background `bg` as the preferences set it (`board.grid`), for the
+/// canvas render to draw under the layers: dark marks on a light page, light ones on a dark page,
+/// in the theme's ink. `None` on design files and with the grid off.
pub fn dots(app: &FigApp, bg: Color32, t: &Tokens) -> Option {
let ds = app.session.ds().ok()?;
if !figcraft_engine::board::is_board(&ds.doc) {
return None;
}
+ let g = app.session.prefs.board_grid.clone().sanitized();
+ let lines = match g.style {
+ GridStyle::None => return None,
+ GridStyle::Dots => false,
+ GridStyle::Lines | GridStyle::Graph => true,
+ };
+ // Dots and plain lines thin out by halves; graph paper keeps its strong lines.
+ let major = if g.style == GridStyle::Graph { g.major } else { 2 };
let zoom = ds.view.zoom;
- let (step, fade) = dot_spacing(zoom)?;
+ let min_gap = if lines { LINE_MIN_GAP } else { DOT_MIN_GAP };
+ let (step, fade) = grid_spacing(zoom, g.spacing, major, min_gap)?;
let dark_page = 0.2126 * f32::from(bg.r()) + 0.7152 * f32::from(bg.g()) + 0.0722 * f32::from(bg.b()) < 128.0;
let ink = if dark_page == t.dark { t.text } else { t.bg };
- let alpha = if dark_page { 0.22 } else { 0.18 };
+ // Lines cover more of the page than dots, so they start fainter.
+ let base = if dark_page { 0.22 } else { 0.18 } * if lines { 0.6 } else { 1.0 };
+ let alpha = (base * g.contrast as f32).clamp(0.0, 1.0);
+ // Graph paper's in-between lines stay fainter than its strong ones even zoomed in.
+ let faded = alpha * fade * if g.style == GridStyle::Graph { 0.5 } else { 1.0 };
let color = |a: f32| figcraft_color::Color::from_rgba8(ink.r(), ink.g(), ink.b(), (a * 255.0).round() as u8);
- let size = (1.5 * zoom.sqrt()).clamp(1.0, 3.0) / zoom;
- Some(figcraft_render::Dots { step, size, color: color(alpha), faded: color(alpha * fade) })
+ // Dots are about a point and a half across on screen, growing a little zoomed in, scaled by
+ // the size setting (1, the default, keeps the usual dots).
+ let k = g.size;
+ let size = if lines { k.max(0.5) / zoom } else { (1.5 * zoom.sqrt()).clamp(1.0, 3.0) * k / zoom };
+ Some(figcraft_render::Dots { step, size, color: color(alpha), faded: color(faded), lines, major })
}
-/// The dot grid's spacing on the page at `zoom` (doubled until the dots are far enough apart on
-/// screen), and how strongly the dots the next doubling drops still show (0 to 1).
-pub(crate) fn dot_spacing(zoom: f64) -> Option<(f64, f32)> {
- if !(zoom.is_finite() && zoom > 0.0) {
+/// The grid's spacing on the page at `zoom`, starting from `base` and multiplied by `major` until
+/// the marks are at least `min_gap` apart on screen, and how strongly the marks the next step
+/// drops still show (0 to 1).
+pub(crate) fn grid_spacing(zoom: f64, base: f64, major: u32, min_gap: f64) -> Option<(f64, f32)> {
+ let ok = |v: f64| v.is_finite() && v > 0.0;
+ if !(ok(zoom) && ok(base) && ok(min_gap)) {
return None;
}
- // Bounded: past about 1000 doublings the step is no longer finite, and there are no dots.
- let mut step = DOT_STEP;
+ let factor = f64::from(major.max(2));
+ // Bounded: past about 1000 multiplications the step is no longer finite, and there is no grid.
+ let mut step = base;
for _ in 0..1100 {
- if step * zoom >= DOT_MIN_GAP {
+ if step * zoom >= min_gap {
break;
}
- step *= 2.0;
+ step *= factor;
}
let gap = step * zoom;
- // From one to two minimum gaps apart, the in-between dots fade in.
- (gap.is_finite() && gap >= DOT_MIN_GAP).then(|| (step, ((gap - DOT_MIN_GAP) / DOT_MIN_GAP).clamp(0.0, 1.0) as f32))
+ // From one to two minimum gaps apart, the in-between marks fade in.
+ (gap.is_finite() && gap >= min_gap).then(|| (step, ((gap - min_gap) / min_gap).clamp(0.0, 1.0) as f32))
+}
+
+/// The Preferences dialog's board grid settings (`board.grid`): its style, then (with a grid)
+/// how far apart, how large and how strong its marks are, and graph paper's cells per strong line.
+pub(crate) fn grid_settings(app: &mut FigApp, ui: &mut Ui) {
+ let g = app.session.prefs.board_grid.clone().sanitized();
+ let mut set = None;
+ ui.horizontal(|ui| {
+ ui.label(tr("Board grid"));
+ let styles = [
+ (GridStyle::Dots, tr_noop("Dots")),
+ (GridStyle::Lines, tr_noop("Lines")),
+ (GridStyle::Graph, tr_noop("Graph paper")),
+ (GridStyle::None, tr_noop("None")),
+ ];
+ for (style, label) in styles {
+ if ui.selectable_label(g.style == style, tr(label)).clicked() {
+ set = Some(json!({"style": style.id()}));
+ }
+ }
+ });
+ if g.style != GridStyle::None {
+ ui.horizontal(|ui| {
+ let (mut spacing, mut major) = (g.spacing, g.major);
+ ui.label(tr("Spacing"));
+ let (lo, hi) = figcraft_engine::board_grid::SPACING;
+ if ui.add(egui::DragValue::new(&mut spacing).range(lo..=hi)).changed() {
+ set = Some(json!({"spacing": spacing}));
+ }
+ if g.style == GridStyle::Graph {
+ ui.label(tr("Strong line every"));
+ let (lo, hi) = figcraft_engine::board_grid::MAJOR;
+ if ui.add(egui::DragValue::new(&mut major).range(lo..=hi)).changed() {
+ set = Some(json!({"major": major}));
+ }
+ }
+ });
+ ui.horizontal(|ui| {
+ let (mut size, mut contrast) = (g.size, g.contrast);
+ ui.label(tr("Size"));
+ let (lo, hi) = figcraft_engine::board_grid::SIZE;
+ if ui.add(egui::Slider::new(&mut size, lo..=hi).max_decimals(2)).changed() {
+ set = Some(json!({"size": size}));
+ }
+ ui.label(tr("Contrast"));
+ let (lo, hi) = figcraft_engine::board_grid::CONTRAST;
+ if ui.add(egui::Slider::new(&mut contrast, lo..=hi).max_decimals(2)).changed() {
+ set = Some(json!({"contrast": contrast}));
+ }
+ });
+ if g != Default::default() && crate::widgets::button(ui, tr("Reset grid"), false, true).clicked() {
+ set = Some(json!({"reset": true}));
+ }
+ }
+ if let Some(p) = set {
+ app.run("board.grid", p);
+ }
}
/// The name of the person who placed the stamp under the pointer, beside it.
diff --git a/crates/ui-egui/src/canvas/comments.rs b/crates/ui-egui/src/canvas/comments.rs
index 7e3f84cf..a9e9c126 100644
--- a/crates/ui-egui/src/canvas/comments.rs
+++ b/crates/ui-egui/src/canvas/comments.rs
@@ -630,6 +630,19 @@ fn hover_button(ui: &mut Ui, id: Id, icon: Icon, show: bool, tip: &str) -> egui:
resp.on_hover_text(tip)
}
+/// A message's author, with a small "Agent" label when an AI agent wrote it.
+fn author_name(ui: &mut Ui, t: &Tokens, m: &Message) {
+ ui.label(egui::RichText::new(&m.author).font(semibold(12.0)).color(t.text));
+ if !m.agent {
+ return;
+ }
+ let galley = ui.painter().layout_no_wrap(tr("Agent").to_string(), semibold(10.0), t.text_secondary);
+ let (r, resp) = ui.allocate_exact_size(galley.size() + vec2(8.0, 2.0), Sense::hover());
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tr("Agent")));
+ ui.painter().rect_filled(r, CornerRadius::same(4), t.hover);
+ ui.painter().galley(r.center() - galley.size() / 2.0, galley, t.text_secondary);
+}
+
/// What every message of a thread needs to know.
struct ThreadCtx<'a> {
c: &'a Comment,
@@ -659,7 +672,7 @@ fn message(
let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover());
avatar(ui.painter(), r.center(), 12.0, &m.author);
ui.add_space(4.0);
- ui.label(egui::RichText::new(&m.author).font(semibold(12.0)).color(t.text));
+ author_name(ui, &t, m);
let when = if m.edited > 0 { trf("{when} · edited", &[("when", &ago(m.created))]) } else { ago(m.created) };
ui.label(egui::RichText::new(when).font(regular(11.0)).color(t.text_tertiary));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs
index c3bb6589..725957f9 100644
--- a/crates/ui-egui/src/canvas/guides.rs
+++ b/crates/ui-egui/src/canvas/guides.rs
@@ -23,7 +23,8 @@ enum GuideDrag {
/// Dragging a new guide out of a ruler.
New { vertical: bool, frame: Option },
/// Moving guide `index` of `owner` (a page or frame); `copy` (Alt at the press) drops a new one.
- Move { owner: NodeId, index: usize, vertical: bool, copy: bool, frame: Option },
+ /// `from` is where it was pressed, so a click without movement only selects it.
+ Move { owner: NodeId, index: usize, vertical: bool, copy: bool, frame: Option, from: Pos2 },
}
impl GuideDrag {
@@ -97,23 +98,28 @@ fn place(doc: &Document, owner: NodeId, index: usize, vertical: bool, offset: f6
Some(Placed { owner, index, vertical, offset, line: Some(guides::segment(doc, owner, vertical, offset)?) })
}
-/// Guide `index` of `owner` (a page or frame) through page point `p`, its offset rounded to whole
-/// pixels with `snap` (else to hundredths).
-fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, snap: bool) -> Option {
+/// Guide `index` of `owner` (a page or frame) through page point `p`. Its offset snaps to a nearby
+/// layer edge or centre when `object_zoom` is set (`None`: object snapping off), then to whole
+/// pixels with `pixel` (else to hundredths); a snapped offset keeps the edge exactly.
+fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, pixel: bool, object_zoom: Option) -> Option {
let ds = app.session.ds().ok()?;
let v = guides::offset_at(&ds.doc, owner, vertical, p)?;
- let offset = if snap { v.round() } else { (v * 100.0).round() / 100.0 };
+ let offset = match object_zoom.and_then(|zoom| figcraft_engine::snap::snap_guide(&ds.doc, ds.page, owner, vertical, v, zoom)) {
+ Some(snapped) => snapped,
+ None if pixel => v.round(),
+ None => (v * 100.0).round() / 100.0,
+ };
if owner == ds.page { Some(Placed { owner, index, vertical, offset, line: None }) } else { place(&ds.doc, owner, index, vertical, offset) }
}
/// A guide dragged out of a ruler (`vertical`: the left one) and dropped at page point `p`: on the
/// top-level frame under it, in the frame's coordinates and along its axis nearer the ruler's, or
/// on the page.
-fn drop_target(app: &FigApp, p: Point, vertical: bool, snap: bool) -> Option {
+fn drop_target(app: &FigApp, p: Point, vertical: bool, pixel: bool, object_zoom: Option) -> Option {
let ds = app.session.ds().ok()?;
match guides::drop_owner(&ds.doc, ds.page, p) {
- f if f != ds.page => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, snap),
- page => through(app, page, usize::MAX, vertical, p, snap),
+ f if f != ds.page => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, pixel, object_zoom),
+ page => through(app, page, usize::MAX, vertical, p, pixel, object_zoom),
}
}
@@ -182,18 +188,28 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
return false;
}
let r = app.canvas.rect;
- let (pressed, released, alt) = ctx.input(|i| {
- (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary), i.modifiers.alt)
+ let (pressed, released, alt, suspend) = ctx.input(|i| {
+ (
+ i.pointer.button_pressed(egui::PointerButton::Primary),
+ i.pointer.button_released(egui::PointerButton::Primary),
+ i.modifiers.alt,
+ i.modifiers.command || i.modifiers.ctrl,
+ )
});
if let Some(drag) = ctx.data(|d| d.get_temp::(drag_id())) {
let vertical = match drag {
GuideDrag::New { vertical, .. } | GuideDrag::Move { vertical, .. } => vertical,
};
let p = app.to_page(pos);
- let snap = app.session.ds().map(|d| d.view.snap_pixel).unwrap_or(true);
+ let (pixel, object_zoom) =
+ app.session.ds().map(|d| (d.view.snap_pixel, (d.view.snap_objects && !suspend).then_some(d.view.zoom))).unwrap_or((true, None));
let at = match drag {
- GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, snap),
- GuideDrag::Move { owner, index, vertical, .. } => through(app, owner, index, vertical, p, snap),
+ GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, pixel, object_zoom),
+ // A click only selects the guide: object snapping starts once the pointer moves.
+ GuideDrag::Move { owner, index, vertical, from, .. } => {
+ let moved = (pos - from).length() > 0.5;
+ through(app, owner, index, vertical, p, pixel, if moved { object_zoom } else { None })
+ }
};
// Alt: distances from the guide to the layers in the frame it crosses.
let frame = drag.frame().or_else(|| at.map(|g| g.owner).filter(|o| app.session.ds().is_ok_and(|d| d.page != *o)));
@@ -272,7 +288,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
app.ui.context_menu_shown = false;
} else {
ctx.data_mut(|d| {
- d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical, copy: alt, frame })
+ d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical, copy: alt, frame, from: pos })
});
}
}
diff --git a/crates/ui-egui/src/canvas/outline.rs b/crates/ui-egui/src/canvas/outline.rs
index e5a589d8..a0dc2945 100644
--- a/crates/ui-egui/src/canvas/outline.rs
+++ b/crates/ui-egui/src/canvas/outline.rs
@@ -138,16 +138,31 @@ pub(super) fn outline_colour(doc: &Document, id: NodeId, green: Color32) -> Colo
if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { green }
}
-/// The outline path of shapes (ellipses, vectors) for hover/selection outlines.
-fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option>> {
- let n = doc.node(id)?;
- if !matches!(
- n.kind,
- NodeKind::Ellipse { .. } | NodeKind::Vector { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::BooleanOp { .. }
- ) {
+/// The path a layer's hover and selection outline follows, in its own space, when that isn't just
+/// its box: the shape of ellipses, vectors, polygons, stars and boolean groups, and of rectangles
+/// and frames with rounded corners (each corner's radius and the smoothing, as the fill draws them,
+/// whatever the stroke's alignment).
+pub(super) fn outline_path(doc: &Document, n: &figcraft_doc::Node) -> Option {
+ let shaped = match n.kind {
+ NodeKind::Ellipse { .. } | NodeKind::Vector { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::BooleanOp { .. } => true,
+ NodeKind::Rectangle
+ | NodeKind::Frame
+ | NodeKind::Component { .. }
+ | NodeKind::ComponentSet { .. }
+ | NodeKind::Instance { .. }
+ | NodeKind::Section { .. } => n.corners.radii.iter().any(|r| *r > 0.0),
+ _ => false,
+ };
+ if !shaped {
return None;
}
- let (path, _) = figcraft_render::node_outline(doc, n)?;
+ figcraft_render::node_outline(doc, n).map(|(p, _)| p)
+}
+
+/// [`outline_path`] on screen, flattened into polylines.
+fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option>> {
+ let n = doc.node(id)?;
+ let path = outline_path(doc, n)?;
let w = doc.world_transform(id);
let mut out = Vec::new();
let mut cur: Vec = Vec::new();
@@ -180,8 +195,7 @@ fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option {
diff --git a/crates/ui-egui/src/canvas/outline_tests.rs b/crates/ui-egui/src/canvas/outline_tests.rs
new file mode 100644
index 00000000..115b8314
--- /dev/null
+++ b/crates/ui-egui/src/canvas/outline_tests.rs
@@ -0,0 +1,48 @@
+//! A layer's hover and selection outline follows its geometry (#1506): a rectangle or frame with
+//! rounded corners is outlined along each corner's radius and its smoothing, whatever the stroke's
+//! alignment; one with square corners keeps its box.
+
+use figcraft_doc::{Document, NodeId, geometry::shape_path};
+use figcraft_engine::Session;
+use figcraft_geom::kurbo::{BezPath, PathEl, Shape};
+use serde_json::{Value, json};
+
+use super::outline::outline_path;
+
+fn layer(s: &mut Session, kind: &str, props: Value) -> NodeId {
+ let v = s.execute("node.create", &json!({"type": kind, "x": 10, "y": 20, "width": 465, "height": 254})).unwrap();
+ let id = NodeId(v["id"].as_u64().unwrap());
+ s.execute("node.set", &json!({"id": id, "props": props})).unwrap();
+ id
+}
+
+fn path_of(doc: &Document, id: NodeId) -> Option {
+ outline_path(doc, doc.node(id).unwrap())
+}
+
+#[test]
+fn rounded_corners_are_outlined_along_the_geometry_for_every_stroke_alignment() {
+ let mut s = Session::new();
+ for align in ["inside", "center", "outside"] {
+ let props = json!({"stroke": "#000000", "strokeWeight": 12, "strokeAlign": align, "cornerSmoothing": 0.6,
+ "topLeftRadius": 0, "topRightRadius": 60, "bottomRightRadius": 20, "bottomLeftRadius": 43});
+ for kind in ["rectangle", "frame"] {
+ let id = layer(&mut s, kind, props.clone());
+ let doc = s.doc().unwrap();
+ let n = doc.node(id).unwrap();
+ let p = path_of(doc, id).unwrap_or_else(|| panic!("{kind}, {align}: a rounded outline"));
+ assert_eq!(Some(&p), shape_path(n).as_ref(), "{kind}, {align}: the layer's own geometry");
+ // The geometry's box, not grown or shrunk by the stroke.
+ let b = p.bounding_box();
+ assert!([b.x0, b.y0, b.x1 - 465.0, b.y1 - 254.0].iter().all(|d| d.abs() < 1e-6), "{kind}, {align}: {b:?}");
+ assert!(p.elements().iter().any(|e| matches!(e, PathEl::CurveTo(..))), "{kind}, {align}: curved corners");
+ }
+ }
+}
+
+#[test]
+fn square_corners_keep_the_box() {
+ let mut s = Session::new();
+ let id = layer(&mut s, "rectangle", json!({"stroke": "#000000", "strokeWeight": 4, "strokeAlign": "outside"}));
+ assert!(path_of(s.doc().unwrap(), id).is_none());
+}
diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs
index 2d4d82d5..5e932324 100644
--- a/crates/ui-egui/src/canvas/overlays.rs
+++ b/crates/ui-egui/src/canvas/overlays.rs
@@ -71,6 +71,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { accent(painter) }, 1.0));
}
slot_badges(app, painter, &doc, &sel, ov.drop_target);
+ attach_points(app, painter, &ov.attach);
if let Some(t) = ov.swap_target {
swap_highlight(app, painter, &doc, t);
}
@@ -333,6 +334,18 @@ fn selection_overlay(
size_label
}
+/// The connector tool's attach points on the object under the pointer; the one its end is at is a
+/// larger see-through ring, so the end's cap shows inside it.
+fn attach_points(app: &FigApp, painter: &egui::Painter, points: &[([f64; 2], bool)]) {
+ let col = accent(painter);
+ for ([x, y], on) in points {
+ let c = app.to_screen(*x, *y);
+ let (r, fill) = if *on { (7.0, col.gamma_multiply(0.25)) } else { (4.0, Color32::WHITE) };
+ painter.circle_filled(c, r, fill);
+ painter.circle_stroke(c, r, stroke(col, 1.5));
+ }
+}
+
fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) {
let ov = &app.session.tool.overlay;
if !ov.pen.is_empty() {
@@ -369,7 +382,8 @@ fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) {
}
}
} else if let Some([x0, y0, x1, y1]) = ov.rubber {
- // A connector being drawn, or one of its ends dragged: from where it starts to the pointer.
+ // A selected connector's end being dragged: from its other end to the pointer (one being
+ // drawn shows itself, previewed in the document).
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.5));
}
}
diff --git a/crates/ui-egui/src/canvas/proto_canvas.rs b/crates/ui-egui/src/canvas/proto_canvas.rs
index b5d402b4..1178ed6b 100644
--- a/crates/ui-egui/src/canvas/proto_canvas.rs
+++ b/crates/ui-egui/src/canvas/proto_canvas.rs
@@ -10,6 +10,8 @@ use serde_json::{Value, json};
use crate::{FigApp, RightTab};
+mod overlay_ghost;
+
const HANDLE_R: f32 = 7.0;
/// A connection being dragged out of a layer's handle.
@@ -159,6 +161,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
let open = crate::proto_panel::editor(ctx);
let hover = ctx.input(|i| i.pointer.hover_pos());
let dragging: Option = ctx.data(|d| d.get_temp(drag_id()));
+ overlay_ghost::draw(app, painter, doc, page);
for nd in noodles(app, ctx, doc, page) {
let selected = sel.contains(&nd.source);
let is_open = open == Some((nd.source, nd.index));
@@ -233,6 +236,7 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool {
if drag.is_some() {
ctx.data_mut(|d| d.remove::(drag_id()));
}
+ overlay_ghost::end(app, &ctx);
return false;
}
let (pos, pressed, down, released) = ctx.input(|i| {
@@ -243,6 +247,9 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool {
i.pointer.button_released(egui::PointerButton::Primary),
)
});
+ if drag.is_none() && overlay_ghost::input(app, &ctx, pos, pos.is_some_and(|p| app.canvas.rect.contains(p)) && resp.hovered()) {
+ return true;
+ }
let Ok(ds) = app.session.ds() else { return false };
let (doc, page, sel) = (ds.doc.clone(), ds.page, ds.selection.clone());
if let Some(mut d) = drag {
diff --git a/crates/ui-egui/src/canvas/proto_canvas/overlay_ghost.rs b/crates/ui-egui/src/canvas/proto_canvas/overlay_ghost.rs
new file mode 100644
index 00000000..21599010
--- /dev/null
+++ b/crates/ui-egui/src/canvas/proto_canvas/overlay_ghost.rs
@@ -0,0 +1,283 @@
+//! The ghost of a manually placed overlay: while the interaction editor has an Open overlay action
+//! with Manual position open, the overlay frame shows on the canvas where it will open (its offset
+//! from the top-left of the layer that opens it), and dragging the ghost moves it. The drag is one
+//! undo step, and the action's X and Y fields follow it live.
+
+use std::sync::Arc;
+
+use egui::{Color32, CornerRadius, Id, Pos2, Rect, Stroke, StrokeKind, pos2};
+use figcraft_doc::proto::{Action, MAX_ACTION_DEPTH, OverlayPosition};
+use figcraft_doc::{Document, NodeId};
+use serde_json::json;
+
+use crate::FigApp;
+use crate::proto_editor::{Slot, run_at};
+
+/// The overlay thumbnail's longest side (pixels).
+const GHOST_PX: f32 = 1024.0;
+/// The undo step a drag makes (the label of the command it runs).
+const LABEL: &str = "Edit prototype action";
+
+/// The Manual overlay action being edited.
+struct Manual {
+ slot: Slot,
+ action: Action,
+ dest: NodeId,
+ /// The trigger layer's top-left (page units).
+ origin: [f64; 2],
+ /// The overlay's top-left from `origin`; without one, right below the trigger layer.
+ offset: [f64; 2],
+}
+
+/// A ghost being dragged: the action at the press, the page point grabbed and the last offset set.
+#[derive(Clone)]
+struct Drag {
+ slot: Slot,
+ action: Action,
+ grab: [f64; 2],
+ start: [f64; 2],
+ last: [f64; 2],
+}
+
+fn drag_id() -> Id {
+ Id::unique("proto_overlay_ghost_drag")
+}
+
+/// The first Open overlay action with Manual position in `actions` (conditional blocks too); its
+/// path is left in `path`.
+fn find_manual(actions: &[Action], path: &mut Vec, depth: usize) -> Option {
+ for (i, a) in actions.iter().enumerate() {
+ path.push(i);
+ if matches!(a, Action::OpenOverlay { position: OverlayPosition::Manual, .. }) {
+ return Some(a.clone());
+ }
+ if let Action::Conditional { blocks } = a
+ && depth < MAX_ACTION_DEPTH
+ && let Some(found) = blocks.iter().enumerate().find_map(|(b, block)| {
+ let mut inner = vec![b];
+ let found = find_manual(&block.actions, &mut inner, depth + 1)?;
+ path.extend(inner);
+ Some(found)
+ })
+ {
+ return Some(found);
+ }
+ path.pop();
+ }
+ None
+}
+
+/// The Manual overlay of interaction `k` of `node`, if it has one.
+fn manual(doc: &Document, node: NodeId, k: usize) -> Option {
+ let it = doc.node(node)?.interactions.get(k)?;
+ let mut path = Vec::new();
+ let action = find_manual(&it.actions, &mut path, 0)?;
+ let Action::OpenOverlay { dest, offset, .. } = &action else { return None };
+ let trigger = doc.world_bounds(node)?;
+ let (dest, offset) = (*dest, offset.unwrap_or([0.0, trigger.height()]));
+ Some(Manual { slot: Slot { node, k, path }, action, dest, origin: [trigger.x0, trigger.y0], offset })
+}
+
+/// The Manual overlay the interaction editor has open on the shown page.
+fn editing(app: &FigApp, ctx: &egui::Context, doc: &Document, page: NodeId) -> Option {
+ let (node, k) = crate::proto_panel::editor(ctx)?;
+ if !super::active(app) || doc.page_of(node) != Some(page) {
+ return None;
+ }
+ manual(doc, node, k)
+}
+
+/// Screen rect of page rect (`x`, `y`, `w`, `h`).
+fn screen(app: &FigApp, [x, y]: [f64; 2], w: f64, h: f64) -> Rect {
+ Rect::from_min_max(app.to_screen(x, y), app.to_screen(x + w, y + h))
+}
+
+/// Where the overlay frame opens, on screen.
+fn ghost_rect(app: &FigApp, doc: &Document, m: &Manual) -> Option {
+ let b = doc.world_bounds(m.dest)?;
+ Some(screen(app, [m.origin[0] + m.offset[0], m.origin[1] + m.offset[1]], b.width(), b.height()))
+}
+
+/// The overlay frame's thumbnail and the page area it shows (effects reach past the frame), kept
+/// while the frame's layers stay the same: dragging edits only the trigger layer.
+#[derive(Clone)]
+struct Thumb {
+ doc: Arc,
+ dest: NodeId,
+ tex: Option,
+}
+
+fn thumbnail(ctx: &egui::Context, doc: &Arc, dest: NodeId) -> Option {
+ let id = Id::unique("proto_overlay_ghost_thumb");
+ let kept: Option = ctx.data(|d| d.get_temp(id));
+ let tex = match kept {
+ Some(t) if t.dest == dest && (Arc::ptr_eq(&t.doc, doc) || crate::assets_panel::same_layers(&t.doc, doc, dest)) => t.tex,
+ _ => crate::assets_panel::thumbnail(ctx, doc, dest, GHOST_PX),
+ };
+ ctx.data_mut(|d| d.insert_temp(id, Thumb { doc: doc.clone(), dest, tex: tex.clone() }));
+ tex
+}
+
+/// Draw the ghost of the Manual overlay being edited: the frame, dimmed, outlined in the accent.
+pub(super) fn draw(app: &FigApp, painter: &egui::Painter, doc: &Arc, page: NodeId) {
+ let ctx = painter.ctx();
+ let Some(m) = editing(app, ctx, doc, page) else { return };
+ let Some(r) = ghost_rect(app, doc, &m) else { return };
+ let accent = crate::theme::canvas_accent(ctx);
+ let art = figcraft_render::export_bounds(doc, m.dest).zip(doc.world_bounds(m.dest));
+ match (thumbnail(ctx, doc, m.dest), art) {
+ (Some(tex), Some((e, b))) => {
+ let at = [m.origin[0] + m.offset[0] + e.x0 - b.x0, m.origin[1] + m.offset[1] + e.y0 - b.y0];
+ let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
+ painter.image(tex.id(), screen(app, at, e.width(), e.height()), uv, Color32::WHITE.gamma_multiply(0.6));
+ }
+ _ => {
+ painter.rect_filled(r, CornerRadius::ZERO, accent.gamma_multiply(0.12));
+ }
+ }
+ painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.5, accent), StrokeKind::Outside);
+}
+
+/// Move the dragged ghost so the grabbed point is under `p`.
+fn drag_to(app: &mut FigApp, ctx: &egui::Context, mut d: Drag, p: Pos2) {
+ let at = app.to_page(p);
+ let o = [(d.start[0] + at.x - d.grab[0]).round(), (d.start[1] + at.y - d.grab[1]).round()].map(|v| v.clamp(-100_000.0, 100_000.0));
+ if o != d.last {
+ let mut action = d.action.clone();
+ if let Action::OpenOverlay { offset, .. } = &mut action {
+ *offset = Some(o);
+ }
+ run_at(app, &d.slot, "proto.action.set", json!({ "action": action }));
+ d.last = o;
+ }
+ ctx.data_mut(|m| m.insert_temp(drag_id(), d));
+}
+
+/// End a drag under way, as one undo step.
+pub(super) fn end(app: &mut FigApp, ctx: &egui::Context) {
+ if ctx.data(|m| m.get_temp::(drag_id())).is_some() {
+ ctx.data_mut(|m| m.remove::(drag_id()));
+ let _ = app.session.commit_interaction();
+ }
+}
+
+/// Canvas input for the ghost: a press on it starts a drag, which follows the pointer until the
+/// button is released. Returns true while it has the gesture.
+pub(super) fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: bool) -> bool {
+ let (pressed, down) = ctx.input(|i| (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.primary_down()));
+ if let Some(d) = ctx.data(|m| m.get_temp::(drag_id())) {
+ ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
+ ctx.request_repaint();
+ match pos {
+ Some(p) if down => drag_to(app, ctx, d, p),
+ _ => end(app, ctx),
+ }
+ return true;
+ }
+ let Some(p) = pos.filter(|_| hovered) else { return false };
+ let Ok(ds) = app.session.ds() else { return false };
+ let (doc, page) = (ds.doc.clone(), ds.page);
+ let Some(m) = editing(app, ctx, &doc, page).filter(|m| ghost_rect(app, &doc, m).is_some_and(|r| r.contains(p))) else { return false };
+ ctx.set_cursor_icon(egui::CursorIcon::Grab);
+ if !pressed || app.session.begin_interaction(LABEL).is_err() {
+ return false;
+ }
+ let at = app.to_page(p);
+ let d = Drag { slot: m.slot, action: m.action, grab: [at.x, at.y], start: m.offset, last: m.offset };
+ ctx.data_mut(|m| m.insert_temp(drag_id(), d));
+ true
+}
+
+#[cfg(test)]
+mod tests {
+ use egui::{Event, PointerButton, vec2};
+ use figcraft_doc::NodeId;
+ use figcraft_doc::proto::{Action, OverlayAnchor, OverlayPosition};
+ use figcraft_engine::Session;
+ use serde_json::json;
+
+ use super::{find_manual, ghost_rect, manual};
+ use crate::RightTab;
+ use crate::present_tests::{Ui, id};
+
+ fn overlay(dest: NodeId, position: OverlayPosition, offset: Option<[f64; 2]>) -> Action {
+ Action::OpenOverlay { dest, position, close_on_outside: true, background: None, offset, anchor: OverlayAnchor::Screen }
+ }
+
+ /// A button in a frame opening frame `b` as a Manual overlay 10, 20 from the button's corner,
+ /// with the interaction editor open on it.
+ fn scene() -> (Ui, NodeId, NodeId) {
+ let mut s = Session::new();
+ let mut ex = |c: &str, p: serde_json::Value| s.execute(c, &p).unwrap();
+ ex("frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}));
+ let btn = id(&ex("shape.rectangle", json!({"x": 40, "y": 30, "width": 80, "height": 30})));
+ let b = id(&ex("frame.create", json!({"x": 600, "y": 0, "width": 160, "height": 100})));
+ ex("proto.interaction.add", json!({"ids": [btn], "dest": b}));
+ ex("proto.action.set", json!({"ids": [btn], "index": 0, "path": [0], "action": overlay(b, OverlayPosition::Manual, Some([10.0, 20.0]))}));
+ ex("select.set", json!({"ids": [btn]}));
+ let mut u = Ui::new(s);
+ u.app.ui.right_tab = RightTab::Prototype;
+ u.run("view.zoomFit", json!({}));
+ crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
+ u.frames(2, Vec::new);
+ (u, btn, b)
+ }
+
+ fn offset(u: &Ui, btn: NodeId) -> Option<[f64; 2]> {
+ let doc = u.app.session.doc().unwrap();
+ match doc.node(btn).unwrap().interactions[0].actions.first() {
+ Some(Action::OpenOverlay { offset, .. }) => *offset,
+ _ => None,
+ }
+ }
+
+ #[test]
+ fn dragging_the_ghost_moves_the_overlay_by_the_dragged_amount_in_one_undo_step() {
+ let (mut u, btn, _) = scene();
+ let doc = u.app.session.doc().unwrap().clone();
+ let r = ghost_rect(&u.app, &doc, &manual(&doc, btn, 0).unwrap()).unwrap();
+ let (from, to) = (r.center(), r.center() + vec2(60.0, 45.0));
+ let moved = [u.app.to_page(to).x - u.app.to_page(from).x, u.app.to_page(to).y - u.app.to_page(from).y];
+ let steps = u.app.session.undo_labels(100).len();
+ u.button(from, true);
+ for k in 1..=3 {
+ u.frame(vec![Event::PointerMoved(from + vec2(20.0, 15.0) * k as f32)]);
+ }
+ assert_eq!(offset(&u, btn), Some([10.0 + moved[0].round(), 20.0 + moved[1].round()]), "the X/Y follow while dragging");
+ u.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Default::default() }]);
+ u.frames(2, Vec::new);
+ assert_eq!(offset(&u, btn), Some([10.0 + moved[0].round(), 20.0 + moved[1].round()]));
+ assert_eq!(u.app.session.undo_labels(100).len(), steps + 1, "one undo step");
+ u.app.session.undo().unwrap();
+ assert_eq!(offset(&u, btn), Some([10.0, 20.0]));
+ }
+
+ #[test]
+ fn without_an_offset_the_ghost_sits_right_below_the_trigger() {
+ let mut s = Session::new();
+ let btn = id(&s.execute("shape.rectangle", &json!({"x": 5, "y": 7, "width": 50, "height": 20})).unwrap());
+ let b = id(&s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 100, "height": 80})).unwrap());
+ s.execute("proto.interaction.add", &json!({"ids": [btn], "dest": b})).unwrap();
+ s.execute("proto.action.set", &json!({"ids": [btn], "index": 0, "path": [0], "action": overlay(b, OverlayPosition::Manual, None)})).unwrap();
+ let m = manual(&s.doc().unwrap().clone(), btn, 0).unwrap();
+ assert_eq!((m.origin, m.offset, m.dest), ([5.0, 7.0], [0.0, 20.0], b));
+ }
+
+ #[test]
+ fn only_a_manual_overlay_has_a_ghost_and_nested_ones_are_found() {
+ let d = NodeId(9);
+ let centred = overlay(d, OverlayPosition::Center, None);
+ let mut path = Vec::new();
+ assert!(find_manual(std::slice::from_ref(¢red), &mut path, 0).is_none());
+ assert!(path.is_empty());
+ let nested = Action::Conditional {
+ blocks: vec![
+ figcraft_doc::proto::ConditionalBlock { condition: None, actions: vec![centred.clone()] },
+ figcraft_doc::proto::ConditionalBlock { condition: None, actions: vec![Action::Back, overlay(d, OverlayPosition::Manual, None)] },
+ ],
+ };
+ let found = find_manual(&[centred, nested], &mut path, 0);
+ assert!(matches!(found, Some(Action::OpenOverlay { position: OverlayPosition::Manual, .. })));
+ assert_eq!(path, vec![1, 1, 1]);
+ }
+}
diff --git a/crates/ui-egui/src/canvas/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs
index 94b2d3d8..38279b96 100644
--- a/crates/ui-egui/src/canvas/render_worker.rs
+++ b/crates/ui-egui/src/canvas/render_worker.rs
@@ -15,13 +15,16 @@
//! The browser renders on the UI thread, so a zoom's full render blocks the page: there a preview
//! is refined only once no newer request has come for [`SETTLE_MS`] (the zoom has paused), or for
//! as long as the last full render took when that is shorter (a wait never costs more than the
-//! render it may save).
+//! render it may save), and the full render is then drawn a band at a time
+//! ([`figcraft_render::Steps`]), at least a band a frame and more while they fit in [`STEP_MS`],
+//! so input is handled while it draws (on a page whose render takes seconds, a band is a
+//! fraction of that: eight at most).
use std::sync::Arc;
use figcraft_color::ColorProfile;
use figcraft_doc::{Document, NodeId};
-use figcraft_render::{Pixmap, Retained, View, Work};
+use figcraft_render::{Begun, Pixmap, Retained, Steps, View, Work};
// A monotonic clock in the browser too: preview decisions rest on how long renders take there.
use web_time::Instant;
@@ -33,6 +36,17 @@ pub const PREVIEW_AFTER_MS: f64 = 12.0;
/// long as the last full render took, when shorter): a full render there blocks the page, and one
/// between every two wheel steps of a zoom held it up for seconds.
pub const SETTLE_MS: u64 = 120;
+/// The browser draws a full render a band at a time ([`figcraft_render::Steps`]): at least one
+/// band a frame, and more while they fit in this long, so input is handled while it draws. A
+/// band of a render that takes seconds takes a fraction of that (see [`MAX_BANDS`]).
+pub const STEP_MS: f64 = 24.0;
+/// Bands are at least this many rows ...
+const MIN_BAND_ROWS: u32 = 64;
+/// ... and a view has at most this many: a frame draws at least one band, so the longest frame of
+/// a render drawn in steps is about a band's time, a part of the render rather than the whole of it
+/// (0.7–1.4 s where the render took 3.7–4.0 s, measured for #1496; more bands would repeat more of
+/// the overlap and of the per-band work).
+const MAX_BANDS: u32 = 8;
#[derive(Clone)]
pub struct Job {
@@ -100,11 +114,9 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP
}
}
-/// Render `view` of the job (its own view, or the coarse one standing in for it).
-fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option {
- let t0 = Instant::now();
- // The canvas is a preview: what's too small to see in detail at this zoom is approximated.
- let opts = figcraft_render::Options {
+/// The canvas is a preview: what's too small to see in detail at this zoom is approximated.
+fn options(job: &Job) -> figcraft_render::Options {
+ figcraft_render::Options {
outline_mode: job.outline,
outline: job.outline_extras,
outlined: job.outlined.clone(),
@@ -114,14 +126,23 @@ fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option<
magnified_image_pixels: true,
dots: job.dots,
..Default::default()
- };
- let (view, pix, work) = retained.render(&job.doc, job.page, view, &opts)?;
+ }
+}
+
+/// Render `view` of the job (its own view, or the coarse one standing in for it).
+fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option {
+ let t0 = Instant::now();
+ let (view, pix, work) = retained.render(&job.doc, job.page, view, &options(job))?;
+ Some(done(job, view, pix, work, t0.elapsed().as_secs_f64() * 1000.0, coarse))
+}
+
+/// The image `pix` of `view`, rendered for `job` in `ms`.
+fn done(job: &Job, view: View, pix: &Pixmap, work: Work, ms: f64, coarse: bool) -> Done {
let mut image = screen_image(pix, job.doc.color_profile);
if job.proof {
mark_outside_srgb(&mut image, pix, job.doc.color_profile);
}
- let ms = t0.elapsed().as_secs_f64() * 1000.0;
- Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at, seq: 0 })
+ Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at, seq: 0 }
}
/// `view` with [`PREVIEW_FACTOR`] times fewer pixels each way, showing at least the same area.
@@ -141,6 +162,56 @@ pub struct Renders {
full_ms: f64,
/// Previews come first only when renders from scratch take at least this long.
preview_after_ms: f64,
+ /// A full render being drawn band by band (the browser): the job it was started for, the
+ /// font generation then, the bands and the time they took so far.
+ steps: Option,
+}
+
+/// A full render drawn in steps and what it is of: a newer job of the same file, page, zoom, size
+/// and render options keeps it going (its image is then patched to the job, as a pan or an edit
+/// is), any other drops it.
+struct Stepping {
+ job: Job,
+ fonts: u64,
+ steps: Box,
+ spent: f64,
+}
+
+/// Whether `a` and `b` are drawn the same way: the same file, page, zoom, size and options (the
+/// view may be elsewhere and the document edited: a render of one is patched to the other).
+fn same_settings(a: &Job, b: &Job) -> bool {
+ a.uid == b.uid
+ && a.page == b.page
+ && a.view.scale.to_bits() == b.view.scale.to_bits()
+ && (a.view.width, a.view.height) == (b.view.width, b.view.height)
+ && a.outline == b.outline
+ && a.outline_extras == b.outline_extras
+ && a.outlined == b.outlined
+ && a.skip == b.skip
+ && a.solo == b.solo
+ && a.proof == b.proof
+ && a.dots == b.dots
+}
+
+/// Whether a render drawn for `a` (with fonts of generation `fonts`) is `b`'s image as it is:
+/// the same settings, document and view, and no fonts arrived meanwhile.
+fn same_request(a: &Job, fonts: u64, b: &Job) -> bool {
+ same_settings(a, b)
+ && Arc::ptr_eq(&a.doc, &b.doc)
+ && a.view.origin.0.to_bits() == b.view.origin.0.to_bits()
+ && a.view.origin.1.to_bits() == b.view.origin.1.to_bits()
+ && figcraft_text::layout_generation() == fonts
+}
+
+/// What [`Renders::refine_for`] got to.
+pub enum Refined {
+ /// The image (`None`: nothing could be drawn).
+ Done(Option),
+ /// Bands remain: call again (next frame).
+ More,
+ /// A full render drawn for what has changed since (an edit, a pan or images that came while
+ /// it was drawn), shown meanwhile; it is drawn again: call again.
+ Shown(Done),
}
impl Default for Renders {
@@ -152,7 +223,7 @@ impl Default for Renders {
impl Renders {
/// Previews when renders from scratch take at least `ms` (tests: 0, always).
pub fn with_threshold(ms: f64) -> Self {
- Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms }
+ Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms, steps: None }
}
/// Whether `job` is a render from scratch: the kept image is of another zoom, page or file, or
@@ -164,7 +235,13 @@ impl Renders {
/// Whether `job` gets a preview first: it renders from scratch and those have been slow on a
/// page already drawn (the first render has no measure).
fn wants_preview(&self, job: &Job) -> bool {
- job.zoom_preview && self.last.is_some() && self.full_ms >= self.preview_after_ms && self.starts_over(job)
+ job.zoom_preview && self.last.is_some() && self.full_ms_so_far() >= self.preview_after_ms && self.starts_over(job)
+ }
+
+ /// How long the last render from scratch took, or what the one being drawn in steps has taken
+ /// so far when that is more: a render dropped mid-way (a zoom going on) leaves a measure too.
+ pub fn full_ms_so_far(&self) -> f64 {
+ self.full_ms.max(self.steps.as_ref().map_or(0.0, |s| s.spent))
}
/// Whether `job` should get a preview should its render run long: from scratch, with none first.
@@ -191,6 +268,107 @@ impl Renders {
}
done
}
+
+ /// [`Self::refine`] for about `budget_ms` at a time (the browser): a full render is drawn band by
+ /// band over several calls, the kept image (re-projected) or the preview showing meanwhile.
+ pub fn refine_for(&mut self, job: &Job, budget_ms: f64) -> Refined {
+ let t0 = Instant::now();
+ if let Some(s) = self.steps.as_ref()
+ && !same_settings(&s.job, job)
+ {
+ // Dropped for a render of the new view. What its bands took is at least what a full
+ // render takes: enough for the next zoom to get its preview and its wait.
+ self.full_ms = self.full_ms.max(s.spent);
+ self.steps = None;
+ }
+ if self.steps.is_none()
+ && let Some(r) = self.begin(job, t0, None)
+ {
+ return r;
+ }
+ let Some(s) = self.steps.as_mut() else { return Refined::Done(None) };
+ let steps = &mut s.steps;
+ let stepped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ loop {
+ if steps.step() {
+ return true;
+ }
+ if t0.elapsed().as_secs_f64() * 1000.0 >= budget_ms {
+ return false;
+ }
+ }
+ }));
+ s.spent += t0.elapsed().as_secs_f64() * 1000.0;
+ match stepped {
+ Ok(false) => Refined::More,
+ Ok(true) => {
+ let Some(Stepping { job: drawn_for, fonts, steps, spent }) = self.steps.take() else { return Refined::Done(None) };
+ let (view, pix) = self.full.finish(*steps);
+ self.full_ms = spent;
+ // The image of the job it was started for: its document's colour profile, its
+ // proof setting and its request time.
+ let drawn = done(&drawn_for, view, pix, Work::Full, spent, false);
+ // Drawn for this very request: shown, done. Otherwise kept, then started on like
+ // any render: an edit, a pan or images that came meanwhile are patched in, or it
+ // is drawn again in steps (never all at once here), this one shown till then.
+ if same_request(&drawn_for, fonts, job) {
+ return Refined::Done(self.finished(job, Some(drawn)));
+ }
+ self.begin(job, t0, Some(drawn)).unwrap_or(Refined::More)
+ }
+ Err(_) => {
+ self.steps = None;
+ self.full.clear();
+ Refined::Done(None)
+ }
+ }
+ }
+
+ /// Start `job` on the kept image: done when it is reused or patched, `None` when a full render
+ /// is now drawn in steps. `drawn` is the kept image just drawn in steps: what a reuse shows,
+ /// else shown while it is drawn again.
+ fn begin(&mut self, job: &Job, t0: Instant, mut drawn: Option) -> Option {
+ // The snapped view may be a row taller.
+ let rows = job.view.height.saturating_add(1).div_ceil(MAX_BANDS).max(MIN_BAND_ROWS);
+ let begun =
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match self.full.begin(&job.doc, job.page, &job.view, &options(job), rows) {
+ Some(Begun::Done(view, pix, work)) => Err(Box::new(match drawn.take() {
+ Some(d) if work == Work::Reused => Some(d),
+ _ => Some(done(job, view, pix, work, t0.elapsed().as_secs_f64() * 1000.0, false)),
+ })),
+ Some(Begun::Steps(steps)) => Ok(steps),
+ None => Err(Box::new(None)),
+ }));
+ match begun {
+ Ok(Ok(steps)) => {
+ // A pan or an edit at this zoom builds on this render: no preview for it.
+ self.last = Some((job.uid, job.page, job.view.scale));
+ self.steps = Some(Stepping { job: job.clone(), fonts: figcraft_text::layout_generation(), steps, spent: 0.0 });
+ drawn.map(Refined::Shown)
+ }
+ Ok(Err(d)) => Some(Refined::Done(self.finished(job, *d))),
+ Err(_) => {
+ self.full.clear();
+ Some(Refined::Done(None))
+ }
+ }
+ }
+
+ /// Record a render done at once in [`Self::refine_for`].
+ fn finished(&mut self, job: &Job, d: Option) -> Option {
+ if let Some(d) = &d {
+ self.last = Some((job.uid, job.page, job.view.scale));
+ if d.work == Work::Full {
+ self.full_ms = d.ms;
+ }
+ }
+ d
+ }
+
+ /// Whether a full render is being drawn band by band.
+ pub fn stepping(&self) -> bool {
+ self.steps.is_some()
+ }
}
/// The coarse preview of the whole of `job`'s view, kept in `retained`.
@@ -385,6 +563,12 @@ impl Inline {
renders: Renders::default(),
}
}
+ /// Previewing when renders from scratch take at least `ms` (tests: 0, whatever the machine).
+ #[cfg(test)]
+ pub fn with_threshold(mut self, ms: f64) -> Self {
+ self.renders = Renders::with_threshold(ms);
+ self
+ }
pub fn submit(&mut self, job: Job) {
self.pending = Some(job);
self.submitted += 1;
@@ -396,26 +580,43 @@ impl Inline {
self.pending.is_some()
}
pub fn poll(&mut self) -> Option {
+ self.poll_at(Instant::now())
+ }
+ /// [`Self::poll`] as of `now` (tests pass the time, so a busy machine can't stretch the wait).
+ pub fn poll_at(&mut self, now: Instant) -> Option {
if !self.previewed {
self.previewed = true;
if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) {
self.preview_shown = true;
- self.since = Instant::now();
- let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms / 1000.0);
+ self.since = now;
+ let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms_so_far() / 1000.0);
self.wait = full.map_or(self.settle, |full| full.min(self.settle));
self.ctx.request_repaint_after(self.wait);
return Some(answering(d, self.submitted));
}
}
if self.pending.is_some() && self.preview_shown {
- let waited = self.since.elapsed();
+ let waited = now.saturating_duration_since(self.since);
if waited < self.wait {
self.ctx.request_repaint_after(self.wait - waited);
return None;
}
}
- let job = self.pending.take()?;
- self.renders.refine(&job).map(|d| answering(d, self.submitted))
+ let job = self.pending.as_ref()?;
+ match self.renders.refine_for(job, STEP_MS) {
+ Refined::Done(d) => {
+ self.pending = None;
+ d.map(|d| answering(d, self.submitted))
+ }
+ Refined::More => {
+ self.ctx.request_repaint();
+ None
+ }
+ Refined::Shown(d) => {
+ self.ctx.request_repaint();
+ Some(answering(d, self.submitted))
+ }
+ }
}
}
diff --git a/crates/ui-egui/src/canvas/zoom_preview_tests.rs b/crates/ui-egui/src/canvas/zoom_preview_tests.rs
index 68b11870..62951f8b 100644
--- a/crates/ui-egui/src/canvas/zoom_preview_tests.rs
+++ b/crates/ui-egui/src/canvas/zoom_preview_tests.rs
@@ -7,18 +7,20 @@
//! long, from the watchdog beside the render thread.
use std::sync::Arc;
+use std::time::Duration;
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 web_time::Instant;
use super::{CanvasState, shows};
use figcraft_render::Retained;
use crate::render_worker::{
- Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now,
+ Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Refined, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now,
};
/// A page of ellipses with a rectangle on top; the session and the rectangle.
@@ -81,7 +83,7 @@ fn job(doc: &Arc, page: NodeId, uid: u64, view: View) -> Job {
dots: None,
uid,
zoom_preview: true,
- at: web_time::Instant::now(),
+ at: Instant::now(),
}
}
@@ -90,6 +92,53 @@ fn same_pixels(a: &Done, b: &Done) -> bool {
a.image.size == b.image.size && a.image.pixels == b.image.pixels
}
+/// The same image up to where render bands meet ([`figcraft_render::Steps`] and the desktop's
+/// parallel bands cut the view in different places): on each row, under a quarter of a level on
+/// average and at most one pixel in twenty off.
+fn close_pixels(a: &Done, b: &Done) -> bool {
+ let [w, h] = a.image.size;
+ a.image.size == b.image.size
+ && (0..h).all(|y| {
+ let row = |d: &Done| d.image.pixels[y * w..(y + 1) * w].to_vec();
+ let (ra, rb) = (row(a), row(b));
+ let diffs: Vec =
+ ra.iter().zip(&rb).map(|(p, q)| p.to_array().iter().zip(q.to_array()).map(|(x, y)| u32::from(x.abs_diff(y))).sum()).collect();
+ let sum: u32 = diffs.iter().sum();
+ f64::from(sum) / (w as f64 * 4.0) < 0.25 && diffs.iter().filter(|d| **d > 0).count() * 20 <= w
+ })
+}
+
+/// Poll the browser's worker until it shows the full image (drawn band by band over polls): the
+/// last image it hands out before nothing is left in flight. (Fonts another test loads meanwhile
+/// make a render that is drawn show, then draw again: only the last one is the request's.)
+fn refined(w: &mut Inline) -> Option {
+ refined_at(w, Instant::now())
+}
+
+/// [`refined`] with the worker's clock at `now`, so a wait it times is up to the test, not to how
+/// busy the machine is.
+fn refined_at(w: &mut Inline, now: Instant) -> Option {
+ let mut last = None;
+ for _ in 0..1000 {
+ last = w.poll_at(now).or(last);
+ if !w.in_flight() {
+ return last;
+ }
+ }
+ None
+}
+
+/// Refine `job` through `r` with no time to spare (a band a call); the image and how many calls.
+fn stepped(r: &mut Renders, job: &Job) -> (Option, usize) {
+ for n in 1..1000 {
+ match r.refine_for(job, 0.0) {
+ Refined::Done(d) => return (d, n),
+ Refined::Shown(_) | Refined::More => {}
+ }
+ }
+ (None, 1000)
+}
+
fn covers(outer: &View, inner: &View) -> bool {
let (o, i) = (outer.page_rect(), inner.page_rect());
let eps = 1e-6;
@@ -388,80 +437,85 @@ fn a_preview_replaces_a_refined_image_of_the_same_zoom_elsewhere_on_the_page() {
#[test]
fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_frame() {
- // Renders from scratch at 200% and 100% (whose times decide the previews below) well over the
- // threshold, wherever this runs.
let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200);
let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS);
let ds = s.ds().unwrap();
let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
- // The production threshold, as the browser has it; refined the next frame (settling: below).
- let mut w = Inline::with_settle(egui::Context::default(), std::time::Duration::ZERO);
+ // Previewing once renders from scratch have taken any time at all: a render the worker didn't
+ // time (0 ms) gives none. (The production threshold against this scene's speed would hang on
+ // how busy the machine is when it runs.) Refined the next frame (settling: below).
+ let mut w = Inline::with_settle(egui::Context::default(), Duration::ZERO).with_threshold(1e-9);
w.submit(at(2.0));
- let first = w.poll().expect("the first image");
+ let first = refined(&mut w).expect("the first image");
assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by");
- assert!(first.ms >= PREVIEW_AFTER_MS, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms);
- // A new zoom: the preview this frame, the refined image the next.
+ assert!(first.ms > 0.0, "renders are timed in the browser worker too: {} ms", first.ms);
+ // A new zoom: the preview this frame, the refined image over the next (a band each).
w.submit(at(1.0));
let p = w.poll().expect("a preview");
assert!(p.coarse && w.in_flight());
- let d = w.poll().expect("the refined image");
+ let d = refined(&mut w).expect("the refined image");
assert!(!d.coarse && !w.in_flight());
- assert!(same_pixels(&d, &render_now(&at(1.0)).unwrap()));
+ assert!(close_pixels(&d, &render_now(&at(1.0)).unwrap()), "drawn band by band");
// A newer request before the refinement: refining the old one is skipped.
w.submit(at(0.5));
assert!(w.poll().is_some_and(|d| d.coarse));
w.submit(at(0.25));
let p = w.poll().expect("the newer one's preview");
assert!(p.coarse && (p.view.scale - 0.25 / PREVIEW_FACTOR).abs() < 1e-12);
- assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 0.25));
+ assert!(refined(&mut w).is_some_and(|d| !d.coarse && d.view.scale == 0.25));
assert!(w.poll().is_none() && !w.in_flight());
}
+/// The browser's worker waiting `settle` after a preview, and previewing every render from scratch
+/// after the first: what its wait tests check can't hang on how fast this machine renders.
+fn waiting(settle: Duration) -> Inline {
+ Inline::with_settle(egui::Context::default(), settle).with_threshold(0.0)
+}
+
#[test]
fn the_browser_worker_refines_a_zoom_only_once_it_settles() {
let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200);
- let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS);
+ let s = document().0;
let ds = s.ds().unwrap();
let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
- let settle = std::time::Duration::from_millis(SETTLE_MS);
- let mut w = Inline::with_settle(egui::Context::default(), settle);
+ let settle = Duration::from_millis(SETTLE_MS);
+ let mut w = waiting(settle);
w.submit(at(2.0));
- assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait");
+ assert!(refined(&mut w).is_some_and(|d| !d.coarse), "the first render: no preview, no wait");
// Zooming: each step shows its preview, never a full render in between.
+ let now = Instant::now();
for scale in [1.5, 1.0] {
w.submit(at(scale));
- assert!(w.poll().is_some_and(|d| d.coarse));
- assert!(w.poll().is_none() && w.in_flight(), "not refined while the zoom may go on");
+ assert!(w.poll_at(now).is_some_and(|d| d.coarse));
+ assert!(w.poll_at(now).is_none() && w.in_flight(), "not refined while the zoom may go on");
}
- std::thread::sleep(settle);
- let d = w.poll().expect("refined once it settled");
+ let d = refined_at(&mut w, now + settle).expect("refined once it settled");
assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight());
// A pan at the same zoom has no preview and is refined at once.
w.submit(job(&ds.doc, ds.page, ds.uid, view_at((340.0, 200.0), 1.0, 1200, 1200)));
- assert!(w.poll().is_some_and(|d| !d.coarse));
+ assert!(refined(&mut w).is_some_and(|d| !d.coarse));
}
/// The wait counts from the preview being shown, not from the request: rendering the preview can
/// take longer than the wait (the page is blocked meanwhile, so no newer request could come), and
/// counted from the request the refinement would follow in the very next frame. The time the
-/// preview's render would take is stood in for by a sleep between the request and the poll.
+/// preview's render would take is stood in for by polling that much after the request.
#[test]
fn the_wait_counts_from_the_preview_not_from_the_request() {
let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200);
- // Renders from scratch of at least the settle, so the wait is the whole settle (see the cap).
- let s = slow_scene(&[view(2.0), view(1.0)], SETTLE_MS as f64);
+ let s = document().0;
let ds = s.ds().unwrap();
let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
- let settle = std::time::Duration::from_millis(SETTLE_MS);
- let mut w = Inline::with_settle(egui::Context::default(), settle);
+ let settle = Duration::from_millis(SETTLE_MS);
+ let mut w = waiting(settle);
w.submit(at(2.0));
- assert!(w.poll().is_some_and(|d| !d.coarse));
- w.submit(at(1.0));
- std::thread::sleep(settle + std::time::Duration::from_millis(30));
- assert!(w.poll().is_some_and(|d| d.coarse), "the preview, longer after the request than the wait");
- assert!(w.poll().is_none() && w.in_flight(), "the wait starts only now");
- std::thread::sleep(settle);
- assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 1.0));
+ assert!(refined(&mut w).is_some_and(|d| !d.coarse));
+ let asked = at(1.0);
+ let shown = asked.at + settle + Duration::from_millis(30);
+ w.submit(asked);
+ assert!(w.poll_at(shown).is_some_and(|d| d.coarse), "the preview, longer after the request than the wait");
+ assert!(w.poll_at(shown).is_none() && w.in_flight(), "the wait starts only now");
+ assert!(refined_at(&mut w, shown + settle).is_some_and(|d| !d.coarse && d.view.scale == 1.0));
}
/// Until the zoom's full render has run, the kept image is of the old zoom, so a pan or an edit
@@ -471,26 +525,26 @@ fn the_wait_counts_from_the_preview_not_from_the_request() {
#[test]
fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_after() {
let view = |c, scale| view_at(c, scale, 1200, 1200);
- let s = slow_scene(&[view((300.0, 200.0), 2.0), view((300.0, 200.0), 1.0)], 3.0 * PREVIEW_AFTER_MS);
+ let s = document().0;
let ds = s.ds().unwrap();
let at = |c, scale| job(&ds.doc, ds.page, ds.uid, view(c, scale));
- let settle = std::time::Duration::from_millis(SETTLE_MS);
- let mut w = Inline::with_settle(egui::Context::default(), settle);
+ let settle = Duration::from_millis(SETTLE_MS);
+ let mut w = waiting(settle);
w.submit(at((300.0, 200.0), 2.0));
- assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait");
+ assert!(refined(&mut w).is_some_and(|d| !d.coarse), "the first render: no preview, no wait");
// A zoom step, then a pan at the new zoom before the pause: a preview each, no full render.
+ let now = Instant::now();
w.submit(at((300.0, 200.0), 1.0));
- assert!(w.poll().is_some_and(|d| d.coarse));
- assert!(w.poll().is_none() && w.in_flight());
+ assert!(w.poll_at(now).is_some_and(|d| d.coarse));
+ assert!(w.poll_at(now).is_none() && w.in_flight());
w.submit(at((340.0, 200.0), 1.0));
- assert!(w.poll().is_some_and(|d| d.coarse), "from scratch still: the kept image is of the old zoom");
- assert!(w.poll().is_none() && w.in_flight(), "the wait starts over");
- std::thread::sleep(settle);
- let d = w.poll().expect("refined once it settled");
+ assert!(w.poll_at(now).is_some_and(|d| d.coarse), "from scratch still: the kept image is of the old zoom");
+ assert!(w.poll_at(now).is_none() && w.in_flight(), "the wait starts over");
+ let d = refined_at(&mut w, now + settle).expect("refined once it settled");
assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight());
// From then on a pan at this zoom builds on the kept image: no preview, refined at once.
w.submit(at((380.0, 200.0), 1.0));
- let d = w.poll().expect("the refined image");
+ let d = refined(&mut w).expect("the refined image");
assert!(!d.coarse && !w.in_flight());
}
@@ -500,19 +554,146 @@ fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_af
#[test]
fn the_wait_is_capped_by_the_time_of_the_last_full_render() {
let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200);
- let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS);
+ let s = document().0;
let ds = s.ds().unwrap();
let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
// A settle far longer than this page's renders: the wait is their time instead.
- let settle = std::time::Duration::from_secs(10);
- let mut w = Inline::with_settle(egui::Context::default(), settle);
+ let settle = Duration::from_secs(10);
+ let mut w = waiting(settle);
w.submit(at(2.0));
- let first = w.poll().expect("the first image");
- assert!(!first.coarse && first.ms >= PREVIEW_AFTER_MS, "slow enough to preview: {} ms", first.ms);
+ let first = refined(&mut w).expect("the first image");
+ assert!(!first.coarse);
+ let full = Duration::from_secs_f64(first.ms / 1000.0);
+ assert!(full < settle, "the cap is what this checks: {} ms", first.ms);
+ let now = Instant::now();
w.submit(at(1.0));
- assert!(w.poll().is_some_and(|d| d.coarse));
- assert!(w.poll().is_none() && w.in_flight(), "not refined in the very next frame");
- std::thread::sleep(std::time::Duration::from_secs_f64(first.ms / 1000.0));
- let d = w.poll().expect("refined once the last full render's time has passed, not the settle");
+ assert!(w.poll_at(now).is_some_and(|d| d.coarse));
+ assert!(w.poll_at(now).is_none() && w.in_flight(), "not refined in the very next frame");
+ assert!(w.poll_at(now + full * 9 / 10).is_none() && w.in_flight(), "nor just before that time");
+ let d = refined_at(&mut w, now + full).expect("refined once the last full render's time has passed, not the settle");
assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight());
}
+
+#[test]
+fn the_browser_draws_a_full_render_band_by_band() {
+ let view = |x| view_at((x, 200.0), 1.0, 600, 600);
+ let s = slow_scene(&[view(300.0)], 3.0 * PREVIEW_AFTER_MS);
+ let ds = s.ds().unwrap();
+ let at = |x| job(&ds.doc, ds.page, ds.uid, view(x));
+ let mut r = Renders::default();
+ let (d, n) = stepped(&mut r, &at(300.0));
+ let d = d.expect("drawn");
+ assert!(n > 2 && !r.stepping(), "several calls, then done ({n})");
+ assert!(close_pixels(&d, &render_now(&at(300.0)).unwrap()));
+ // Kept: a pan now patches it at once.
+ assert!(matches!(r.refine_for(&at(320.0), 0.0), Refined::Done(Some(d)) if d.work != Work::Full));
+}
+
+#[test]
+fn a_pan_while_drawing_finishes_the_render_then_patches_it_and_a_zoom_drops_it() {
+ let view = |x, scale| view_at((x, 200.0), scale, 600, 600);
+ let s = slow_scene(&[view(300.0, 1.0)], 3.0 * PREVIEW_AFTER_MS);
+ let ds = s.ds().unwrap();
+ let at = |x, scale| job(&ds.doc, ds.page, ds.uid, view(x, scale));
+ let mut r = Renders::default();
+ assert!(matches!(r.refine_for(&at(300.0, 1.0), 0.0), Refined::More));
+ // Panned meanwhile: the same render goes on, and what comes is the panned view.
+ let (d, _) = stepped(&mut r, &at(330.0, 1.0));
+ let d = d.expect("drawn");
+ assert!(close_pixels(&d, &render_now(&at(330.0, 1.0)).unwrap()), "the panned view");
+ // Zoomed meanwhile: the render under way is dropped for one of the new zoom.
+ let mut r = Renders::default();
+ assert!(matches!(r.refine_for(&at(300.0, 1.0), 0.0), Refined::More));
+ let (d, _) = stepped(&mut r, &at(300.0, 2.0));
+ assert_eq!(d.expect("drawn").view.scale, 2.0);
+}
+
+#[test]
+fn an_edit_while_drawing_shows_in_what_comes() {
+ let v = view_at((300.0, 200.0), 1.0, 600, 600);
+ let mut s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS);
+ let (doc, page, uid) = {
+ let ds = s.ds().unwrap();
+ (ds.doc.clone(), ds.page, ds.uid)
+ };
+ let mut r = Renders::default();
+ assert!(matches!(r.refine_for(&job(&doc, page, uid, v.clone()), 0.0), Refined::More));
+ // Edited meanwhile, the view unchanged: the render goes on and what comes shows the edit.
+ s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 400, "height": 400})).unwrap();
+ let edited = job(&s.ds().unwrap().doc, page, uid, v);
+ let (d, _) = stepped(&mut r, &edited);
+ assert!(close_pixels(&d.expect("drawn"), &render_now(&edited).unwrap()), "the edit shows");
+}
+
+#[test]
+fn a_render_overtaken_by_a_long_pan_is_shown_then_drawn_again() {
+ let v = view_at((300.0, 200.0), 1.0, 600, 600);
+ let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS);
+ let ds = s.ds().unwrap();
+ let mut r = Renders::default();
+ assert!(matches!(r.refine_for(&job(&ds.doc, ds.page, ds.uid, v.clone()), 0.0), Refined::More));
+ // Panned more than a view meanwhile: nothing to patch. What was drawn shows, then the new view.
+ let far = job(&ds.doc, ds.page, ds.uid, view_at((1500.0, 200.0), 1.0, 600, 600));
+ let mut shown = vec![];
+ let last = (0..1000).find_map(|_| match r.refine_for(&far, 0.0) {
+ Refined::More => None,
+ Refined::Shown(d) => {
+ shown.push(d);
+ None
+ }
+ Refined::Done(d) => Some(d),
+ });
+ assert_eq!(shown.len(), 1, "the render drawn shows once");
+ assert!(close_pixels(&shown[0], &render_now(&job(&ds.doc, ds.page, ds.uid, v)).unwrap()), "the view it was drawn for");
+ assert!(close_pixels(&last.flatten().expect("drawn"), &render_now(&far).unwrap()), "then the new view");
+}
+
+/// Drawn for the very request, the render is done: it is not started on again, even where the
+/// kept image can never be patched (outline mode drawing hidden layers), which drew it again
+/// and again, the page never idle.
+#[test]
+fn a_render_drawn_for_the_request_is_done_even_where_it_could_never_be_patched() {
+ let v = view_at((300.0, 200.0), 1.0, 600, 600);
+ let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS);
+ let ds = s.ds().unwrap();
+ let mut hidden = job(&ds.doc, ds.page, ds.uid, v);
+ hidden.outline = true;
+ hidden.outline_extras.hidden = true;
+ let mut r = Renders::default();
+ let (d, n) = stepped(&mut r, &hidden);
+ assert!(d.is_some_and(|d| !d.coarse && d.work == Work::Full) && !r.stepping(), "done after {n} calls");
+ assert!(n < 100, "drawn once, not over and over ({n} calls)");
+}
+
+/// A change of render options (outline mode here) mid-way drops the render, as a zoom does: the
+/// bands of the old one are not drawn to the end first.
+#[test]
+fn a_change_of_options_while_drawing_drops_the_render() {
+ let v = view_at((300.0, 200.0), 1.0, 600, 600);
+ let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS);
+ let ds = s.ds().unwrap();
+ let plain = job(&ds.doc, ds.page, ds.uid, v.clone());
+ let mut outlined = plain.clone();
+ outlined.outline = true;
+ let (_, alone) = stepped(&mut Renders::default(), &outlined);
+ let mut r = Renders::default();
+ assert!(matches!(r.refine_for(&plain, 0.0), Refined::More));
+ let (d, n) = stepped(&mut r, &outlined);
+ assert!(d.is_some_and(|d| !d.coarse));
+ assert!(n <= alone + 1, "the old render's bands are dropped: {n} calls against {alone} alone");
+}
+
+/// A render dropped mid-way leaves a measure behind: what its bands took is at least what a full
+/// render takes, so the next zoom gets its preview (and, in the browser, its wait) instead of
+/// starting from scratch at once, as many times as the zoom goes on.
+#[test]
+fn a_render_dropped_while_drawing_leaves_its_time_as_the_measure() {
+ let view = |scale| view_at((300.0, 200.0), scale, 600, 600);
+ let s = slow_scene(&[view(1.0)], 3.0 * PREVIEW_AFTER_MS);
+ let ds = s.ds().unwrap();
+ let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
+ // Previews once renders have taken any time at all: a band has, where no measure (0 ms) hasn't.
+ let mut r = Renders::with_threshold(1e-9);
+ assert!(matches!(r.refine_for(&at(1.0), 0.0), Refined::More));
+ assert!(r.preview(&at(2.0)).is_some(), "the zoom gets a preview by the dropped render's measure");
+}
diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs
index 85c8d291..1e0fe8d1 100644
--- a/crates/ui-egui/src/colorpicker_selection_tests.rs
+++ b/crates/ui-egui/src/colorpicker_selection_tests.rs
@@ -58,16 +58,16 @@ fn clicking_the_swatch_again_closes_the_picker() {
let (mut u, a, _) = two();
u.app.run("fill.add", json!({"ids": [a], "color": "#00FF00"}));
let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] };
- u.app.toggle_picker(target.clone(), Some(200.0));
+ u.app.toggle_picker(target.clone(), Some(egui::Rect::from_min_size(egui::pos2(1250.0, 200.0), egui::vec2(16.0, 16.0))));
u.frame(vec![]);
u.frame(vec![]);
assert_eq!(u.app.ui.picker, Some(target.clone()), "the swatch opens the picker");
- u.app.toggle_picker(target.clone(), Some(200.0));
+ u.app.toggle_picker(target.clone(), Some(egui::Rect::from_min_size(egui::pos2(1250.0, 200.0), egui::vec2(16.0, 16.0))));
u.frame(vec![]);
assert!(u.app.ui.picker.is_none(), "the same swatch toggles it shut");
// Another paint of the same layer opens the picker for that one.
let second = PickerTarget::Paint { fills: true, index: 1, ids: vec![a] };
- u.app.toggle_picker(second.clone(), Some(200.0));
+ u.app.toggle_picker(second.clone(), Some(egui::Rect::from_min_size(egui::pos2(1250.0, 200.0), egui::vec2(16.0, 16.0))));
u.frame(vec![]);
assert_eq!(u.app.ui.picker, Some(second), "another paint opens the picker for it");
}
@@ -120,3 +120,98 @@ fn wrapped_page_colour_swatches_stay_inset_inside_the_picker() {
);
}
}
+
+#[test]
+fn opening_the_first_picker_preserves_the_active_text_undo_interaction() {
+ let mut s = Session::new();
+ let text = NodeId(s.execute("text.create", &json!({"text": "Hi"})).unwrap()["id"].as_u64().unwrap());
+ let mut app = crate::FigApp::new(s, Box::new(crate::NoServices));
+ figcraft_engine::textedit::begin(&mut app.session, text, Some(2), false).unwrap();
+ figcraft_engine::textedit::insert(&mut app.session, "a").unwrap();
+ let depth = app.session.undo_depth();
+ app.open_picker(PickerTarget::TextRange { id: text, start: 0, end: 2 }, None);
+ assert!(app.session.ds().unwrap().interaction.is_some());
+ assert_eq!(app.session.undo_depth(), depth);
+ figcraft_engine::textedit::insert(&mut app.session, "b").unwrap();
+ figcraft_engine::textedit::end(&mut app.session).unwrap();
+ app.session.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(app.session.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters, "Hi");
+}
+
+#[test]
+fn the_picker_uses_the_fitted_sidebar_edge() {
+ use egui::{Pos2, RawInput, Rect, vec2};
+ let (mut u, a, _) = two();
+ u.app.ui.right_width = 480.0;
+ u.app.open_picker(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }, None);
+ for _ in 0..6 {
+ u.frame_raw(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 900.0))), ..Default::default() });
+ }
+ let sidebar = u.app.ui.right_panel_rect.unwrap();
+ assert!(sidebar.width() < u.app.ui.right_width, "the requested width does not fit");
+ let pop = u.app.ui.picker_popover.as_ref().unwrap();
+ assert!((pop.rect.max.x - (sidebar.min.x - 8.0)).abs() < 1.0, "{:?} beside {sidebar:?}", pop.rect);
+}
+
+#[test]
+fn moved_page_effect_and_text_range_swatches_still_toggle_the_picker_closed() {
+ use egui::{Id, Pos2};
+ for kind in ["page", "effect", "text"] {
+ let (mut u, a, _) = two();
+ let target = match kind {
+ "page" => {
+ u.app.session.execute("select.none", &json!({})).unwrap();
+ PickerTarget::PageBackground
+ }
+ "effect" => {
+ u.app.session.execute("effect.add", &json!({"id":a,"type":"dropShadow"})).unwrap();
+ u.ctx.data_mut(|d| d.insert_temp(Id::unique(("fx_open", 0usize)), true));
+ PickerTarget::Effect { ids: vec![a], index: 0, secondary: false }
+ }
+ _ => {
+ let text = NodeId(u.app.session.execute("text.create", &json!({"text":"Hello"})).unwrap()["id"].as_u64().unwrap());
+ figcraft_engine::textedit::begin(&mut u.app.session, text, None, false).unwrap();
+ PickerTarget::TextRange { id: text, start: 0, end: 5 }
+ }
+ };
+ u.app.open_picker(target, None);
+ for _ in 0..5 {
+ u.frame(vec![]);
+ }
+ let before = u.app.ui.picker_button.expect(kind);
+ u.app.ui.right_width = 360.0;
+ for _ in 0..5 {
+ u.frame(vec![]);
+ }
+ let after = u.app.ui.picker_button.expect(kind);
+ assert!((after.min.x - before.min.x).abs() > 50.0, "{kind}: swatch moves");
+ u.button(after.center(), true);
+ u.button(after.center(), false);
+ u.frame(vec![egui::Event::PointerMoved(Pos2::new(1430.0, 890.0))]);
+ assert!(u.app.ui.picker.is_none(), "{kind}: the moved swatch closes it");
+ }
+}
+
+#[test]
+fn a_press_in_the_gradient_variable_picker_keeps_the_colour_picker_open() {
+ use egui::{Pos2, RawInput, Rect, pos2, vec2};
+ let (mut u, a, _) = two();
+ u.app.session.execute("fill.set", &json!({"id":a,"paint":{"type":"linearGradient","stops":["#ff0000","#0000ff"]}})).unwrap();
+ u.app.open_picker(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }, None);
+ for _ in 0..5 {
+ u.frame(vec![]);
+ }
+ // Open the same child picker used by a gradient stop, away from the parent's card.
+ let _ = u.ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() }, |ui| {
+ crate::variable_ui::open(ui, &[a], "fills/0/stops/0/color", figcraft_doc::VariableType::Color, pos2(500.0, 180.0));
+ });
+ for _ in 0..5 {
+ u.frame(vec![]);
+ }
+ let child = crate::variable_ui::picker_rect(&u.ctx).unwrap();
+ let at = child.min + vec2(30.0, 20.0);
+ assert!(!u.app.ui.picker_popover.as_ref().unwrap().rect.contains(at));
+ u.button(at, true);
+ assert!(u.app.ui.picker.is_some(), "the child variable picker is part of the interaction");
+ u.button(at, false);
+}
diff --git a/crates/ui-egui/src/comments_tests.rs b/crates/ui-egui/src/comments_tests.rs
index c89a00be..0ccd32f1 100644
--- a/crates/ui-egui/src/comments_tests.rs
+++ b/crates/ui-egui/src/comments_tests.rs
@@ -73,6 +73,20 @@ fn control_channel_tool_switch_closes_the_unposted_composer() {
assert!(comments(&u)["draft"].is_null());
}
+#[test]
+fn a_control_call_for_an_agent_signs_its_comment_and_the_ui_keeps_yours() {
+ let mut u = Ui::new(Session::new());
+ let run = |u: &mut Ui, p: Value| crate::control::handle(&mut u.app, &egui::Context::default(), "engine.execute", &p).unwrap();
+ let c = run(&mut u, json!({"command": "comment.add", "params": {"x": 10, "y": 10, "text": "Bigger please"}}))["id"].clone();
+ run(&mut u, json!({"command": "comment.reply", "agent": "Claude", "params": {"id": c, "text": "Done"}}));
+ u.app.run("comment.reply", json!({"id": c, "text": "Thanks"}));
+ let doc = u.app.session.doc().unwrap();
+ let t = doc.comment(c.as_u64().unwrap()).unwrap();
+ let who: Vec<(&str, bool)> = t.messages().map(|m| (m.author.as_str(), m.agent)).collect();
+ assert_eq!(who, [("You", false), ("Claude (agent)", true), ("You", false)]);
+ assert_eq!(u.app.session.agent, None);
+}
+
#[test]
fn mention_helpers() {
// The `@query` before the cursor.
diff --git a/crates/ui-egui/src/component_swap_default_tests.rs b/crates/ui-egui/src/component_swap_default_tests.rs
index 50b06b8b..c57f1fc2 100644
--- a/crates/ui-egui/src/component_swap_default_tests.rs
+++ b/crates/ui-egui/src/component_swap_default_tests.rs
@@ -16,7 +16,7 @@ fn id(v: serde_json::Value) -> NodeId {
/// A selected "Button" component whose instance-swap property "Icon" defaults to "Heart", then 80
/// more components, the last "Zebra". Returns (ui, button, zebra).
-fn setup() -> (Ui, NodeId, NodeId) {
+pub(crate) fn setup() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
let mut icon = |name: &str, k: usize| {
let f =
@@ -38,16 +38,16 @@ fn setup() -> (Ui, NodeId, NodeId) {
(u, button, zebra)
}
-fn tree(u: &mut Ui) -> Vec {
+pub(crate) fn tree(u: &mut Ui) -> Vec {
u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect()
}
-fn centre(n: &Node) -> Pos2 {
+pub(crate) fn centre(n: &Node) -> Pos2 {
let b = n.bounds().unwrap();
pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
}
-fn click(u: &mut Ui, at: Pos2) {
+pub(crate) fn click(u: &mut Ui, at: Pos2) {
u.button(at, true);
u.button(at, false);
}
@@ -78,7 +78,7 @@ fn swap_default(u: &Ui, button: NodeId) -> Option {
}
/// The default's dropdown on the "Icon" property row, scrolled into view in the Design panel.
-fn default_combo(u: &mut Ui) -> Option {
+pub(crate) fn default_combo(u: &mut Ui) -> Option {
for _ in 0..20 {
let combo = tree(u).into_iter().find(|n| n.role() == Role::ComboBox && n.label() == Some("Icon"))?;
if centre(&combo).y < 860.0 {
diff --git a/crates/ui-egui/src/component_swap_search_tests.rs b/crates/ui-egui/src/component_swap_search_tests.rs
new file mode 100644
index 00000000..03893d20
--- /dev/null
+++ b/crates/ui-egui/src/component_swap_search_tests.rs
@@ -0,0 +1,69 @@
+//! The search at the top of an instance's swap property menu, driven through egui headless
+//! (#1338): typing filters the components by name, an empty result says so, Escape clears the
+//! search and then closes the menu, and Up, Down and Enter pick a match.
+
+use egui::{Event, Key, Modifiers, Pos2};
+use figcraft_doc::{NodeId, NodeKind};
+use serde_json::json;
+
+use crate::component_swap_default_tests::{centre, click, default_combo, setup, tree};
+use crate::layers_drag_tests::Ui;
+
+fn key(u: &mut Ui, key: Key) {
+ for pressed in [true, false] {
+ u.frame(vec![Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }]);
+ }
+}
+
+fn typed(u: &mut Ui, text: &str) {
+ u.frame(vec![Event::Text(text.into())]);
+ u.frame(vec![]);
+}
+
+/// Whether the menu shows a row or text `name` (one that wasn't on screen, at `before`, before it
+/// opened).
+fn shown(u: &mut Ui, before: &[Pos2], name: &str) -> bool {
+ tree(u).iter().any(|n| n.label().or(n.value()) == Some(name) && n.bounds().is_some() && !before.contains(¢re(n)))
+}
+
+fn component(u: &Ui, name: &str) -> NodeId {
+ let doc = &u.app.session.ds().unwrap().doc;
+ doc.nodes().find(|n| n.name == name && matches!(n.kind, NodeKind::Component { .. })).unwrap().id
+}
+
+#[test]
+fn the_swap_menu_filters_as_you_type_and_picks_with_the_keyboard() {
+ let (mut u, button, _) = setup();
+ let r = u.app.session.execute("instance.create", &json!({"component": button.0, "x": 900, "y": 0})).unwrap();
+ let inst = r["id"].as_u64().unwrap();
+ u.app.session.execute("select.set", &json!({"ids": [inst]})).unwrap();
+ let combo = default_combo(&mut u).expect("the instance's Icon property has a dropdown");
+ assert_eq!(combo.value(), Some("Heart"));
+ let before: Vec = tree(&mut u).iter().filter(|n| n.label().is_some()).map(centre).collect();
+ // Open it: the search field has the keyboard, so typing filters straight away.
+ click(&mut u, centre(&combo));
+ assert!(shown(&mut u, &before, "Glyph 1") && shown(&mut u, &before, "Zebra"));
+ typed(&mut u, "zEB");
+ assert!(shown(&mut u, &before, "Zebra"), "a match, in any case");
+ assert!(!shown(&mut u, &before, "Glyph 1"), "the rest are filtered out");
+ typed(&mut u, "x");
+ assert!(shown(&mut u, &before, "No matching components"));
+ assert!(!shown(&mut u, &before, "Zebra"));
+ // Escape clears the search; the menu stays open with everything again, still taking the keyboard.
+ key(&mut u, Key::Escape);
+ assert!(shown(&mut u, &before, "Glyph 1") && shown(&mut u, &before, "Zebra"));
+ // Down twice to the second match, Enter picks it and closes the menu.
+ typed(&mut u, "glyph 7");
+ key(&mut u, Key::ArrowDown);
+ key(&mut u, Key::ArrowDown);
+ key(&mut u, Key::Enter);
+ let props = u.app.session.execute("component.properties", &json!({"id": inst})).unwrap();
+ assert_eq!(props["properties"][0]["value"], json!(component(&u, "Glyph 70").0), "the second of Glyph 7, Glyph 70…");
+ assert!(!shown(&mut u, &before, "Glyph 7"), "picking closes the menu");
+ // With the search empty, Escape closes the menu.
+ let combo = default_combo(&mut u).unwrap();
+ click(&mut u, centre(&combo));
+ assert!(shown(&mut u, &before, "Glyph 1"));
+ key(&mut u, Key::Escape);
+ assert!(!shown(&mut u, &before, "Glyph 1"), "closed");
+}
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index 2b10c341..f2697f58 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -27,13 +27,15 @@
//! - `app.quit {}`
//!
//! Any method takes `window` (an id from `window.list`) to run in that window, with its tabs and
-//! panels; without it, requests go to the window last in front.
+//! panels; without it, requests go to the window last in front. `engine.execute`,
+//! `document.inspect`, `node.get` and `ui.render` also take `document` (an `id` from `doc.list`,
+//! or an open file's path) to act on that file without bringing its tab to the front.
use std::sync::{Arc, Mutex};
use egui::Context;
use figcraft_engine::cmd::comment::{Filter, Sort};
-use figcraft_engine::{PointerEvent, PointerKind};
+use figcraft_engine::{PointerEvent, PointerKind, Session};
use serde_json::{Value, json};
use crate::FigApp;
@@ -356,6 +358,12 @@ fn admit(app: &mut FigApp, r: ControlRequest, window: u64, now: f64) {
}
let path = params.get("path").and_then(Value::as_str).map(str::to_string);
let (document, presentation) = app.windows.presentation(&app.session, window);
+ if let Err(e) = front_only(&app.session, Some(document), "ui.screenshot", ¶ms) {
+ if !canceled {
+ let _ = tx.send(ControlReply { id, result: Err(e) });
+ }
+ return;
+ }
app.capture.shots.push(ShotRequest {
id,
path,
@@ -1332,14 +1340,46 @@ fn ui_set_styles(app: &mut FigApp, p: &Value) -> Result<(), String> {
Ok(())
}
+/// The methods whose `document` may name a file behind the tab in front.
+const DOCUMENT_METHODS: &[&str] = &["engine.execute", "document.inspect", "node.get", "ui.render"];
+
+/// The request's `document` (an `id` from `doc.list`, or an open file's path), when it names one.
+fn document_of(p: &Value) -> Option<&Value> {
+ p.get("document").filter(|d| !d.is_null())
+}
+
+/// Run `f` with the request's `document` in front and the user's tab back afterwards, else on the
+/// tab in front (the same swap a command run in the background makes).
+fn in_document(app: &mut FigApp, p: &Value, f: impl FnOnce(&mut Session) -> Result) -> Result {
+ let Some(target) = document_of(p) else { return f(&mut app.session) };
+ let forward = app.session.bring_forward(target).map_err(|e| e.to_string())?;
+ let r = f(&mut app.session);
+ app.session.put_back(forward);
+ r
+}
+
+/// A method that acts on the tab in front (`front`, a document uid) refuses a `document` naming
+/// another file instead of quietly acting on the wrong one.
+fn front_only(session: &Session, front: Option, method: &str, p: &Value) -> Result<(), String> {
+ let Some(target) = document_of(p) else { return Ok(()) };
+ if Some(session.document_uid(target).map_err(|e| e.to_string())?) == front {
+ return Ok(());
+ }
+ Err(format!("`{method}` acts on the tab in front: `document` can name another open file only for {}", DOCUMENT_METHODS.join(", ")))
+}
+
/// Handle one control method (also used by tests and the headless snapshot tool).
pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Result {
+ if !DOCUMENT_METHODS.contains(&method) {
+ front_only(&app.session, app.session.ds().ok().map(|d| d.uid), method, p)?;
+ }
match method {
"engine.execute" => {
let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?;
let params = p.get("params").cloned().unwrap_or(json!({}));
let layers_generation = app.session.clipboard.layers_generation;
- let r = app.session.execute(cmd, ¶ms).map_err(|e| e.to_string());
+ let agent = p.get("agent").and_then(Value::as_str);
+ let r = in_document(app, p, |s| s.execute_as(agent, cmd, ¶ms).map_err(|e| e.to_string()));
app.clear_comment_draft_after_tool_set(cmd, r.is_ok());
app.invalidate_synthetic_copy(layers_generation);
// Programmatic calls never open dialogs.
@@ -1359,13 +1399,15 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
"document.inspect" => {
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize;
let options = figcraft_engine::inspect::InspectOptions::parse(p)?;
- Ok(figcraft_engine::inspect::document_with_options(&app.session, depth, b(p, "allPages"), &options))
+ in_document(app, p, |s| Ok(figcraft_engine::inspect::document_with_options(s, depth, b(p, "allPages"), &options)))
}
"node.get" => {
let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required")?;
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(1) as usize;
let options = figcraft_engine::inspect::InspectOptions::parse(p)?;
- app.session.doc().map(|d| figcraft_engine::inspect::node_json_with_options(d, id, depth, &options)).map_err(|e| e.to_string())
+ in_document(app, p, |s| {
+ s.doc().map(|d| figcraft_engine::inspect::node_json_with_options(d, id, depth, &options)).map_err(|e| e.to_string())
+ })
}
"window.new" => {
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
@@ -1413,7 +1455,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
if !ids.is_empty() {
params["ids"] = json!(ids);
}
- app.session.execute("file.export", ¶ms).map_err(|e| e.to_string())
+ in_document(app, p, |s| s.execute("file.export", ¶ms).map_err(|e| e.to_string()))
}
"app.quit" => {
app.ui.quit = true;
@@ -1490,3 +1532,6 @@ mod roots_tests;
#[cfg(test)]
mod capture_tests;
+
+#[cfg(test)]
+mod document_tests;
diff --git a/crates/ui-egui/src/control/document_tests.rs b/crates/ui-egui/src/control/document_tests.rs
new file mode 100644
index 00000000..dbba64dc
--- /dev/null
+++ b/crates/ui-egui/src/control/document_tests.rs
@@ -0,0 +1,62 @@
+//! A control request naming its `document` acts on that file, and the tab in front stays in front.
+
+use figcraft_engine::Session;
+use serde_json::{Value, json};
+
+use crate::{FigApp, NoServices};
+
+/// Two files open: "Back" behind "Front", which is in front. Returns the app and Back's id.
+fn two_files() -> (FigApp, u64) {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"name": "Front"})).unwrap();
+ s.docs[0].doc_mut().name = "Back".into();
+ let back = s.docs[0].uid;
+ (FigApp::new(s, Box::new(NoServices)), back)
+}
+
+fn handle(app: &mut FigApp, method: &str, p: Value) -> Result {
+ super::handle(app, &egui::Context::default(), method, &p)
+}
+
+fn layers(app: &FigApp, i: usize) -> usize {
+ let ds = &app.session.docs[i];
+ ds.doc.node(ds.page).map_or(0, |p| p.children.len())
+}
+
+#[test]
+fn a_call_naming_the_back_file_edits_it_and_leaves_the_front_one_alone() {
+ let (mut app, back) = two_files();
+ let rect = json!({"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 20, "height": 10}, "document": back});
+ handle(&mut app, "engine.execute", rect).unwrap();
+ assert_eq!((layers(&app, 0), layers(&app, 1)), (1, 0), "the rectangle landed in Back only");
+ assert_eq!(app.session.active, 1, "Front stays in front");
+ let doc = handle(&mut app, "document.inspect", json!({"depth": 1, "document": back})).unwrap();
+ assert_eq!((doc["name"].as_str(), doc["id"].as_u64()), (Some("Back"), Some(back)));
+ let listed = handle(&mut app, "engine.execute", json!({"command": "doc.list"})).unwrap();
+ assert_eq!(listed[0]["id"], json!(back));
+ assert!(handle(&mut app, "ui.render", json!({"document": back})).is_ok());
+ // An open file's path names it too.
+ app.session.docs[0].path = Some("/work/back.figcraft".into());
+ let doc = handle(&mut app, "document.inspect", json!({"depth": 0, "document": "/work/back.figcraft"})).unwrap();
+ assert_eq!(doc["id"], json!(back));
+ assert_eq!(app.session.active, 1);
+}
+
+#[test]
+fn a_file_that_is_not_open_or_a_ui_method_on_the_back_file_is_an_error() {
+ let (mut app, back) = two_files();
+ let e = handle(&mut app, "document.inspect", json!({"document": 999_999})).unwrap_err();
+ assert!(e.contains("no open document 999999"), "{e}");
+ let e = handle(&mut app, "ui.type", json!({"text": "x", "document": back})).unwrap_err();
+ assert!(e.contains("acts on the tab in front"), "{e}");
+ let front = app.session.docs[1].uid;
+ assert!(handle(&mut app, "ui.inspect", json!({"document": front})).is_ok(), "the file in front may be named");
+ assert_eq!(app.session.active, 1);
+}
+
+#[test]
+fn a_command_that_opens_a_file_still_brings_it_to_the_front() {
+ let (mut app, back) = two_files();
+ handle(&mut app, "engine.execute", json!({"command": "file.new", "document": back})).unwrap();
+ assert_eq!(app.session.active, 2, "a new file opens in front");
+}
diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs
index c87f4f33..e76f2f57 100644
--- a/crates/ui-egui/src/corner_individual_tests.rs
+++ b/crates/ui-egui/src/corner_individual_tests.rs
@@ -70,6 +70,75 @@ fn ios_button_sets_corner_smoothing_to_60_percent() {
assert_eq!(u.app.session.undo_depth().0, history + 1);
}
+/// The visible smoothing icon uses the same scrub gesture, cursor and modifier rates as the
+/// other numeric fields; the whole drag is one undo step, including when clamped at either end.
+#[test]
+fn corner_smoothing_icon_scrubs_with_modifiers_and_one_undo_step() {
+ for (dx, modifiers, want) in
+ [(16.0, Modifiers::NONE, 0.66), (8.0, Modifiers::SHIFT, 1.0), (16.0, Modifiers::ALT, 0.516), (-80.0, Modifiers::NONE, 0.0)]
+ {
+ let (mut u, r) = scene();
+ u.app.run("node.set", json!({"ids": [r], "props": {"cornerSmoothing": 0.5}}));
+ let toggle = named_button(&mut u, "Individual corners");
+ click(&mut u, toggle);
+ let lead = u.ctx.read_response(Id::unique("smoothing").with("lead")).expect("smoothing icon").rect;
+ assert_eq!(lead.width(), 24.0, "the icon has the same drag target as other fields");
+ let from = lead.center();
+ let out = u.frame_output(vec![Event::PointerMoved(from)]);
+ assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal);
+ let history = u.app.session.undo_depth().0;
+ u.frame_with(vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }], modifiers);
+ for step in 1..=4 {
+ u.frame_with(vec![Event::PointerMoved(pos2(from.x + dx * step as f32 / 4.0, from.y))], modifiers);
+ }
+ let smoothing = |u: &Ui| u.app.session.doc().unwrap().node(r).unwrap().corners.smoothing;
+ assert!((smoothing(&u) - want).abs() < 1e-9, "the drag updates the canvas before release");
+ u.frame_with(
+ vec![Event::PointerButton { pos: pos2(from.x + dx, from.y), button: egui::PointerButton::Primary, pressed: false, modifiers }],
+ modifiers,
+ );
+ u.frame(vec![]);
+ assert!((smoothing(&u) - want).abs() < 1e-9, "release keeps the scrubbed value");
+ assert_eq!(u.app.session.undo_depth().0, history + 1);
+ u.app.session.undo().unwrap();
+ assert_eq!(smoothing(&u), 0.5);
+ u.app.session.redo().unwrap();
+ assert!((smoothing(&u) - want).abs() < 1e-9);
+ }
+}
+
+/// Fine drags keep their fractional baseline across gestures, including a later plain drag.
+#[test]
+fn consecutive_smoothing_drags_preserve_fractional_percentages() {
+ let (mut u, r) = scene();
+ u.app.run("node.set", json!({"ids": [r], "props": {"cornerSmoothing": 0.5}}));
+ let toggle = named_button(&mut u, "Individual corners");
+ click(&mut u, toggle);
+ let history = u.app.session.undo_depth().0;
+ let smoothing = |u: &Ui| u.app.session.doc().unwrap().node(r).unwrap().corners.smoothing;
+ for (step, (dx, modifiers, want)) in
+ [(16.0, Modifiers::ALT, 0.516), (16.0, Modifiers::ALT, 0.532), (8.0, Modifiers::NONE, 0.612)].into_iter().enumerate()
+ {
+ let from = u.ctx.read_response(Id::unique("smoothing").with("lead")).unwrap().rect.center();
+ u.frame_with(vec![Event::PointerMoved(from)], modifiers);
+ u.frame_with(vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }], modifiers);
+ for k in 1..=4 {
+ u.frame_with(vec![Event::PointerMoved(pos2(from.x + dx * k as f32 / 4.0, from.y))], modifiers);
+ }
+ u.frame_with(
+ vec![Event::PointerButton { pos: pos2(from.x + dx, from.y), button: egui::PointerButton::Primary, pressed: false, modifiers }],
+ modifiers,
+ );
+ u.frame(vec![]);
+ assert!((smoothing(&u) - want).abs() < 1e-9, "gesture {step}: expected {want}, got {}", smoothing(&u));
+ assert_eq!(u.app.session.undo_depth().0, history + step + 1);
+ }
+ for want in [0.532, 0.516, 0.5] {
+ u.app.session.undo().unwrap();
+ assert!((smoothing(&u) - want).abs() < 1e-9, "undo restores the precise value");
+ }
+}
+
/// The fields sit at their corners, and typing in the bottom right one sets only that corner in a
/// single undo step (#1002: the bottom row showed bottom right on the left).
#[test]
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index b3e1461a..fe3bdb73 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -44,9 +44,15 @@ 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 {
+/// named the graphics API, so the hint is to unset it rather than to try another one. `tried`: the
+/// graphics APIs the app already started again with, so naming another one wouldn't help.
+pub fn window_failed(error: &str, backend_set: bool, tried: Option<&str>) -> String {
+ let hint = if let Some(apis) = tried {
+ trf(
+ "FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps.",
+ &[("apis", &apis)],
+ )
+ } else 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
@@ -264,10 +270,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
}
ui.add_space(8.0);
ui.horizontal(|ui| {
- ui.label(tr("Your name in comments"));
- if let Some(name) = crate::widgets::text_field(ui, egui::Id::unique("pref_user_name"), &prefs.user_name, 160.0, tr("You")) {
- app.run("pref.userName", json!({"name": name}));
- }
+ ui.label(tr("Your name"));
+ crate::live::name_field(app, ui, egui::Id::unique("pref_user_name"), 160.0);
});
ui.add_space(8.0);
ui.horizontal(|ui| {
@@ -300,6 +304,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
ui.add_space(8.0);
interface_size(app, ui, prefs.ui_scale);
ui.add_space(8.0);
+ crate::board_bar::grid_settings(app, ui);
+ ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(tr("Theme"));
for (l, c) in [(tr_noop("Light"), "view.themeLight"), (tr_noop("Dark"), "view.themeDark"), (tr_noop("System"), "view.themeSystem")] {
diff --git a/crates/ui-egui/src/dialogs/plugins_ui.rs b/crates/ui-egui/src/dialogs/plugins_ui.rs
index 339573ff..57491cea 100644
--- a/crates/ui-egui/src/dialogs/plugins_ui.rs
+++ b/crates/ui-egui/src/dialogs/plugins_ui.rs
@@ -252,7 +252,7 @@ fn panel_windows(app: &mut FigApp, ctx: &Context) {
app.run("plugin.close", json!({"id": id}));
}
Some(Used::Set(control, value)) => run(app, "plugin.event", json!({"id": id, "control": control, "value": value})),
- Some(Used::Pick(control)) => app.ui.picker = Some(PickerTarget::PluginField { plugin: id, control, pending: None }),
+ Some(Used::Pick(control)) => app.open_picker(PickerTarget::PluginField { plugin: id, control, pending: None }, None),
}
}
}
@@ -590,7 +590,7 @@ fn form_window(app: &mut FigApp, ctx: &Context) {
});
app.ui.plugin_form = (open && !submit && !cancel).then_some(form.clone());
if let Some(control) = pick {
- app.ui.picker = Some(PickerTarget::PluginField { plugin: form.id.clone(), control, pending: None });
+ app.open_picker(PickerTarget::PluginField { plugin: form.id.clone(), control, pending: None }, None);
}
if submit {
run(app, "plugin.run", json!({"id": form.id, "command": form.command, "params": form.values}));
diff --git a/crates/ui-egui/src/dialogs/variables_panel.rs b/crates/ui-egui/src/dialogs/variables_panel.rs
index 9939cfc9..710c0db5 100644
--- a/crates/ui-egui/src/dialogs/variables_panel.rs
+++ b/crates/ui-egui/src/dialogs/variables_panel.rs
@@ -839,9 +839,8 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C
ui.painter().rect_filled(body, CornerRadius::same(5), t.field);
let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0));
widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(*c));
- if ui.interact(sw.expand(3.0), id.with("swatch"), Sense::click()).tip(tr("Edit colour")).clicked() {
- app.ui.picker = Some(PickerTarget::Variable { id: v.id.clone(), mode: mode.to_string() });
- }
+ let response = ui.interact(sw.expand(3.0), id.with("swatch"), Sense::click()).tip(tr("Edit colour"));
+ app.picker_control(PickerTarget::Variable { id: v.id.clone(), mode: mode.to_string() }, &response);
let hex_r = Rect::from_min_max(pos2(sw.max.x + 8.0, body.min.y + 4.0), pos2(body.max.x - 44.0, body.max.y - 4.0));
if let Some(h) = widgets::text_field_inline(ui, id.with("hex"), &c.to_hex(), hex_r, regular(11.0))
&& let Some(nc) = figcraft_color::Color::from_hex_or_name(&h)
diff --git a/crates/ui-egui/src/display_name_tests.rs b/crates/ui-egui/src/display_name_tests.rs
new file mode 100644
index 00000000..25f37a4f
--- /dev/null
+++ b/crates/ui-egui/src/display_name_tests.rs
@@ -0,0 +1,63 @@
+//! Your name, one for comments and live sessions (#1380): "Your name" in Preferences, shown and
+//! editable in the Join dialog, and kept as soon as it changes.
+
+use std::sync::{Arc, Mutex};
+
+use egui::{Event, Key, Modifiers};
+use figcraft_engine::Prefs;
+
+use crate::Services;
+use crate::live_tests::{Harness, has_text, text_center};
+
+/// Keeps the name of each preferences save.
+struct Kept(Arc>>);
+
+impl Services for Kept {
+ fn save_prefs(&mut self, prefs: &Prefs) {
+ self.0.lock().unwrap().push(prefs.user_name.clone());
+ }
+}
+
+fn harness() -> (Harness, Arc>>) {
+ let (mut h, _) = Harness::new();
+ let kept = Arc::new(Mutex::new(Vec::new()));
+ h.app.services = Box::new(Kept(kept.clone()));
+ (h, kept)
+}
+
+/// Replace the text of the field showing `old` with `new` and press Enter.
+fn retype(h: &mut Harness, old: &str, new: &str) {
+ let shapes = h.frames(1);
+ h.click(text_center(&shapes, old));
+ let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
+ h.frame(vec![key(Key::A, Modifiers::COMMAND)]);
+ h.frame(vec![Event::Text(new.into())]);
+ h.frame(vec![key(Key::Enter, Modifiers::NONE)]);
+ h.frames(1);
+}
+
+#[test]
+fn the_join_dialog_shows_your_name_and_changes_it() {
+ let (mut h, kept) = harness();
+ h.app.session.execute("pref.userName", &serde_json::json!({"name": "Ada"})).unwrap();
+ crate::menus::invoke(&mut h.app, "collab.join");
+ let shapes = h.frames(3);
+ assert!(has_text(&shapes, "Your name") && has_text(&shapes, "Ada"), "the name others will see");
+ retype(&mut h, "Ada", "Bob");
+ assert_eq!(h.app.session.prefs.user_name, "Bob");
+ assert_eq!(kept.lock().unwrap().last().map(String::as_str), Some("Bob"), "kept at once");
+ assert!(h.app.ui.live.join.is_some(), "the dialog stays to join");
+}
+
+#[test]
+fn preferences_call_it_your_name_and_the_default_shows_empty() {
+ let (mut h, kept) = harness();
+ h.app.ui.preferences = true;
+ let shapes = h.frames(3);
+ assert!(has_text(&shapes, "Your name") && !has_text(&shapes, "Your name in comments"));
+ assert!(!has_text(&shapes, "You"), "the default isn't a name to edit");
+ h.app.session.execute("pref.userName", &serde_json::json!({"name": "Ada"})).unwrap();
+ retype(&mut h, "Ada", "Lin");
+ assert_eq!(h.app.session.prefs.user_name, "Lin");
+ assert_eq!(kept.lock().unwrap().as_slice(), ["Lin"]);
+}
diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs
index 2d8f6287..e1d17c27 100644
--- a/crates/ui-egui/src/field_tests.rs
+++ b/crates/ui-egui/src/field_tests.rs
@@ -148,6 +148,33 @@ fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
+#[test]
+fn layout_add_button_requires_every_selected_container_to_have_no_layout() {
+ let mut s = Session::new();
+ let rect = id(s.execute("shape.rectangle", &json!({"width": 40, "height": 20})).unwrap());
+ let group = id(s.execute("object.group", &json!({"ids": [rect]})).unwrap());
+ let frame = id(s.execute("frame.create", &json!({"x": 200, "width": 100, "height": 60})).unwrap());
+ let mut u = Ui::new(s);
+ u.ctx.enable_accesskit();
+ for (selection, layout, expected) in [
+ (vec![group], false, true),
+ (vec![frame, group], false, true),
+ (vec![frame, group], true, false),
+ (vec![frame], true, false),
+ (vec![], true, false),
+ ] {
+ if layout {
+ u.app.session.execute("node.set", &json!({"id": frame, "props": {"layout": "horizontal"}})).unwrap();
+ }
+ u.app.session.execute("select.set", &json!({"ids": selection})).unwrap();
+ u.frame(vec![]);
+ let output = u.frame_output(vec![]);
+ let nodes = output.platform_output.accesskit_update.unwrap().nodes;
+ let has_add = nodes.iter().any(|(_, n)| n.role() == Role::Button && n.label() == Some("Add auto layout"));
+ assert_eq!(has_add, expected, "selection: {selection:?}, frame has layout: {layout}");
+ }
+}
+
/// Rectangles A (10, 20, 40×30) and B (100, 50, 20×20); A is selected.
fn setup() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
@@ -476,14 +503,18 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() {
let [r0, .., a0] = rgba(&u);
u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), 1usize));
u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] });
- // The picker window takes a frame to size itself.
- for _ in 0..3 {
+ // Settle sizing and the anchored position before reading field hit rectangles.
+ for _ in 0..5 {
u.frame(vec![]);
}
let steps = undo_steps(&u);
let from = lead(&u, Id::unique(("cp_f", "r")));
drag(&mut u, from, -20.0, Modifiers::NONE);
assert_eq!((rgba(&u)[0], undo_steps(&u)), (r0 - 20, steps + 1), "R");
+ // Committing adds a recent colour row and can lift the anchored picker. Settle its layout.
+ for _ in 0..3 {
+ u.frame(vec![]);
+ }
// The alpha field: a scrub moves it too.
let from = lead(&u, Id::unique("cp_a"));
drag(&mut u, from, -30.0, Modifiers::NONE);
@@ -504,7 +535,7 @@ fn lch_and_oklch_picker_fields_edit_lightness_keeping_hue() {
assert_eq!(before.to_hex(), "0D99FF");
u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), model));
u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] });
- for _ in 0..3 {
+ for _ in 0..5 {
u.frame(vec![]);
}
let steps = undo_steps(&u);
@@ -524,14 +555,16 @@ fn type_into_picker(u: &mut Ui, id: &str, text: &str) {
click(u, rect.center());
u.frame(vec![Event::Text(text.into())]);
key(u, Key::Enter, Modifiers::NONE);
- u.frame(vec![]);
+ for _ in 0..3 {
+ u.frame(vec![]);
+ }
}
/// A picker on layer `a` in model `model` (an index into the model menu), drawn and settled.
fn open_picker(u: &mut Ui, a: NodeId, model: usize) {
u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), model));
u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] });
- for _ in 0..3 {
+ for _ in 0..5 {
u.frame(vec![]);
}
}
diff --git a/crates/ui-egui/src/guide_snap_tests.rs b/crates/ui-egui/src/guide_snap_tests.rs
new file mode 100644
index 00000000..edf8cb11
--- /dev/null
+++ b/crates/ui-egui/src/guide_snap_tests.rs
@@ -0,0 +1,110 @@
+//! Guides dragged out of a ruler snap to nearby layer edges and centres (Snap to Objects), on the
+//! guide's own axis, and Cmd/Ctrl suspends that while the guides keep their pixel snapping.
+
+use egui::{Event, Modifiers, Pos2};
+use figcraft_doc::NodeId;
+use figcraft_engine::Session;
+use serde_json::json;
+
+use crate::layers_drag_tests::Ui;
+
+fn id(v: serde_json::Value) -> NodeId {
+ NodeId(v["id"].as_u64().unwrap())
+}
+
+fn rulers(mut s: Session) -> Ui {
+ s.execute("view.rulers", &json!({"on": true})).unwrap();
+ Ui::new(s)
+}
+
+fn guides(u: &Ui, owner: NodeId) -> Vec<(bool, f64)> {
+ let ds = u.app.session.ds().unwrap();
+ ds.doc.node(owner).unwrap().guides().iter().map(|g| (g.vertical, g.offset)).collect()
+}
+
+fn page(u: &Ui) -> NodeId {
+ u.app.session.ds().unwrap().page
+}
+
+fn from_top_ruler(u: &Ui) -> Pos2 {
+ let r = u.app.canvas.rect;
+ Pos2::new(r.min.x + r.width() * 0.5, r.min.y + 5.0)
+}
+
+fn from_left_ruler(u: &Ui) -> Pos2 {
+ let r = u.app.canvas.rect;
+ Pos2::new(r.min.x + 5.0, r.min.y + r.height() * 0.5)
+}
+
+fn drag(u: &mut Ui, from: Pos2, to: Pos2) {
+ u.frame(vec![Event::PointerMoved(from)]);
+ u.button(from, true);
+ u.frame(vec![Event::PointerMoved(to)]);
+ u.button(to, false);
+}
+
+fn drag_with(u: &mut Ui, from: Pos2, to: Pos2, m: Modifiers) {
+ u.frame_with(vec![Event::PointerMoved(from)], m);
+ u.frame_with(vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: m }], m);
+ u.frame_with(vec![Event::PointerMoved(to)], m);
+ u.frame_with(vec![Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: m }], m);
+}
+
+#[test]
+fn a_guide_dragged_from_a_ruler_snaps_to_a_layers_edge() {
+ let mut s = Session::new();
+ s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap();
+ let mut u = rulers(s);
+ let start = from_top_ruler(&u);
+ let to = u.app.to_screen(200.0, 102.0);
+ drag(&mut u, start, to);
+ assert_eq!(guides(&u, page(&u)), vec![(false, 100.0)], "two pixels from the top edge");
+}
+
+#[test]
+fn a_guide_dragged_over_a_frame_snaps_to_a_layer_inside_it() {
+ let mut s = Session::new();
+ let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 300, "height": 200})).unwrap());
+ s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
+ let mut u = rulers(s);
+ let start = from_left_ruler(&u);
+ let to = u.app.to_screen(112.0, 200.0);
+ drag(&mut u, start, to);
+ assert_eq!(guides(&u, frame), vec![(true, 10.0)], "two pixels from the child's left edge");
+}
+
+#[test]
+fn with_snap_to_objects_off_the_guide_keeps_its_pixel_rounding() {
+ let mut s = Session::new();
+ s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap();
+ s.execute("view.snapObjects", &json!({"on": false})).unwrap();
+ let mut u = rulers(s);
+ let start = from_top_ruler(&u);
+ let to = u.app.to_screen(200.0, 102.0);
+ drag(&mut u, start, to);
+ assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)], "rounded to the pixel, not the edge");
+}
+
+#[test]
+fn a_click_on_a_guide_only_selects_it() {
+ let mut s = Session::new();
+ s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap();
+ s.execute("guide.add", &json!({"vertical": false, "offset": 102})).unwrap();
+ let mut u = rulers(s);
+ // Two pixels above the rectangle's top edge, but a click without movement leaves it there.
+ let at = u.app.to_screen(500.0, 102.0);
+ u.button(at, true);
+ u.button(at, false);
+ assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)], "a click must not snap it to the edge at 100");
+}
+
+#[test]
+fn cmd_or_ctrl_suspends_guide_snapping() {
+ let mut s = Session::new();
+ s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap();
+ let mut u = rulers(s);
+ let start = from_top_ruler(&u);
+ let to = u.app.to_screen(200.0, 102.0);
+ drag_with(&mut u, start, to, Modifiers::CTRL);
+ assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)]);
+}
diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs
index 05c8fcda..4d891f87 100644
--- a/crates/ui-egui/src/home.rs
+++ b/crates/ui-egui/src/home.rs
@@ -343,16 +343,11 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
if open {
app.open_dialog();
}
- // Join a file someone shared: the join field, and what the join is doing.
+ // Join a file someone shared (the Join dialog shows what the join is doing).
subheading(ui, t, tr("Join with a code"));
ui.scope(|ui| {
ui.set_max_width(540.0);
- if let Some(session) = crate::live::join_row(app, ui, 260.0) {
- app.ui.live.home_join = Some(session);
- }
- if let Some(session) = app.ui.live.home_join {
- crate::live::join_status(app, ui, t, session);
- }
+ crate::live::join_row(app, ui, 260.0);
});
opened_join(app);
if !app.session.prefs.getting_started_dismissed {
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index eeacb275..a891abed 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -89,6 +89,8 @@ icons! {
FlipH,
FlipV,
CornerRadius,
+ /// A smooth corner with its two tangent endpoints.
+ CornerSmoothing,
/// Individual corners: the rounded corner each field sets.
CornerTopLeft,
CornerTopRight,
diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs
index 7eedfdd1..953ef2de 100644
--- a/crates/ui-egui/src/icons/draw.rs
+++ b/crates/ui-egui/src/icons/draw.rs
@@ -510,6 +510,18 @@ fn draw(p: &P, icon: Icon) {
p.fill(&[(3.0, 9.0), (13.0, 9.0), (13.0, 14.0)]);
}
CornerRadius | CornerTopLeft => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0),
+ CornerSmoothing => {
+ // An original superellipse corner, with dots marking its tangent endpoints.
+ let points: Vec<_> = (0..=24)
+ .map(|i| {
+ let a = i as f32 / 24.0 * FRAC_PI_2;
+ (2.5 + 11.0 * (1.0 - a.cos().max(0.0).sqrt()), 2.5 + 11.0 * (1.0 - a.sin().max(0.0).sqrt()))
+ })
+ .collect();
+ p.path(&points, false);
+ p.dot(2.5, 13.5, 1.0);
+ p.dot(13.5, 2.5, 1.0);
+ }
CornerTopRight => p.corner(13.5, 2.5, -1.0, 1.0, 11.5, 6.0),
CornerBottomRight => p.corner(13.5, 13.5, -1.0, -1.0, 11.5, 6.0),
CornerBottomLeft => p.corner(2.5, 13.5, 1.0, -1.0, 11.5, 6.0),
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 3fb1ceb4..30ee36f9 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -63,6 +63,8 @@ mod component_props_reorder_tests;
#[cfg(test)]
mod component_swap_default_tests;
#[cfg(test)]
+mod component_swap_search_tests;
+#[cfg(test)]
mod context_menu_tests;
#[cfg(test)]
mod corner_individual_tests;
@@ -77,6 +79,8 @@ mod dev_mode_tests;
#[cfg(test)]
mod devmode_tail_tests;
#[cfg(test)]
+mod display_name_tests;
+#[cfg(test)]
mod duplicate_paint_tests;
#[cfg(test)]
mod export_preview_tests;
@@ -93,6 +97,8 @@ mod gradient_shorthand_tests;
#[cfg(test)]
mod guide_release_tests;
#[cfg(test)]
+mod guide_snap_tests;
+#[cfg(test)]
mod home_recents_tests;
#[cfg(test)]
mod home_tests;
@@ -121,6 +127,8 @@ mod live_docs_tests;
#[cfg(test)]
mod live_join_tests;
#[cfg(test)]
+mod live_joining_tests;
+#[cfg(test)]
mod live_server_tests;
#[cfg(test)]
mod live_share_tests;
@@ -129,6 +137,10 @@ mod live_tests;
#[cfg(test)]
mod live_text_tests;
#[cfg(test)]
+mod local_fonts_tests;
+#[cfg(test)]
+mod make_slot_naming_tests;
+#[cfg(test)]
mod measure_nudge_tests;
#[cfg(test)]
mod menu_aim_tests;
@@ -345,6 +357,8 @@ pub struct UiState {
/// small window draws).
pub left_width: f32,
pub right_width: f32,
+ /// The right sidebar as drawn this frame, after fitting the window.
+ pub right_panel_rect: Option,
pub right_tab: RightTab,
/// The tab of the previous frame, to see the right panel change mode.
pub last_right_tab: RightTab,
@@ -422,6 +436,8 @@ pub struct UiState {
/// The control that opened the picker, refreshed every frame it is drawn (the panel scrolls):
/// a press in it is held out of the popover's closing, so its own click toggles the picker.
pub picker_button: Option,
+ /// The opening widget, so only that control refreshes the held rectangle as it moves.
+ pub picker_button_id: Option,
pub context_menu: Option<(egui::Pos2, Vec)>,
/// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the
/// focused layer row or the canvas selection and takes the keyboard.
@@ -558,6 +574,7 @@ impl Default for UiState {
Self {
left_width: PANEL_DEFAULT,
right_width: PANEL_DEFAULT,
+ right_panel_rect: None,
right_tab: RightTab::Design,
last_right_tab: RightTab::Design,
left_tab: LeftTab::File,
@@ -603,6 +620,7 @@ impl Default for UiState {
crop: None,
picker_popover: None,
picker_button: None,
+ picker_button_id: None,
context_menu: None,
key_menu: false,
key_box: None,
diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs
index e16d2cfa..cdafd9b8 100644
--- a/crates/ui-egui/src/live.rs
+++ b/crates/ui-egui/src/live.rs
@@ -28,6 +28,8 @@ use crate::right::{menu_item, menu_item_with, menu_popup};
use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::widgets;
+pub mod agent;
+
/// In the browser: what joining needs (a page can't host, and https pages can't reach `ws://`).
const BROWSER_NOTE: &str = tr_noop(
"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).",
@@ -49,6 +51,11 @@ const OWNER_NOTE: &str = tr_noop(
"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.",
);
+/// The same in the browser, which remembers nothing once the page closes.
+const BROWSER_OWNER_NOTE: &str = tr_noop(
+ "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again.",
+);
+
/// What a server open to the public can see.
const PLAIN_NOTE: &str = tr_noop("Files aren't end-to-end encrypted: the server's operator can read them.");
@@ -108,6 +115,9 @@ pub struct LiveUi {
/// The join started from the home screen: what it is doing shows under the field, and its
/// file's tab shows when it opens.
pub home_join: Option,
+ /// A join started from a join field, with what was typed for it: the Join dialog shows it
+ /// from the click until its file is ready, or why it failed, to try again.
+ pub joining: Option<(SessionId, String)>,
/// Remove from server… was clicked: its confirmation shows.
pub confirm_remove: bool,
/// What the server for online sharing said about its hosting (`collab.serverInfo`), and the
@@ -468,8 +478,9 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
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 agent = agent::shown(app, ui.ctx()).map(|p| p.name.clone());
+ // Room for the avatars after yours (and the agent's) and the chevron.
+ let fit = (((area.width() - 2.0 * R - 18.0 - STEP * f32::from(u8::from(agent.is_some()))) / STEP).floor() as usize).max(1);
let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) };
let mut x = area.min.x + R + 2.0;
let ring = |ui: &Ui, c: Pos2| ui.painter().circle_stroke(c, R + 1.0, Stroke::new(2.0, t.panel));
@@ -521,6 +532,10 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
ui.painter().circle_filled(c, R, t.pressed);
ui.painter().text(c, Align2::CENTER_CENTER, format!("+{more}"), semibold(10.0), t.text);
}
+ if let Some(name) = &agent {
+ x += STEP;
+ agent::avatar(ui, pos2(x, cy), R, name);
+ }
let chev = Rect::from_center_size(pos2(x + R + 10.0, cy), vec2(16.0, 24.0));
let cresp = ui.interact(chev, ui.scope_id().with("live_people"), Sense::click());
a11y::menu_button(&cresp, tr("People in this file"), a11y::menu_open(&cresp));
@@ -528,14 +543,38 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
ui.painter().rect_filled(chev, CornerRadius::same(4), t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(chev.center(), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary);
- menu_popup(&cresp).width(220.0).show(|ui| {
+ clicked = people_menu(app, &cresp, (&me_name, me_fill), &peers, follow).or(clicked);
+ if let Some(id) = clicked {
+ let params = if follow == Some(id) { json!({}) } else { json!({"client": id}) };
+ app.run("collab.follow", params);
+ }
+ 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 people row's chevron menu: you and everyone else; the person clicked (to follow).
+fn people_menu(
+ app: &FigApp,
+ cresp: &egui::Response,
+ (me_name, me_fill): (&str, Color32),
+ peers: &[(u32, String, Color32)],
+ follow: Option,
+) -> Option {
+ let t = Tokens::get(&cresp.ctx);
+ let mut clicked = None;
+ menu_popup(cresp).width(220.0).show(|ui| {
let row = |ui: &mut Ui, label: &str, fill: Color32, check: bool| {
menu_item_with(ui, label, if check { tr("Following") } else { "" }, 22.0, |p, r, _| {
avatar(p, pos2(r.max.x - 15.0, r.center().y), 8.0, label, fill)
})
};
row(ui, &trf("{name} (you)", &[("name", &me_name)]), me_fill, false);
- for (id, name, fill) in &peers {
+ for (id, name, fill) in peers {
if row(ui, name, *fill, follow == Some(*id)) {
clicked = Some(*id);
ui.close();
@@ -549,17 +588,7 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
);
}
});
- if let Some(id) = clicked {
- let params = if follow == Some(id) { json!({}) } else { json!({"client": id}) };
- app.run("collab.follow", params);
- }
- 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(|_, _, _| {}));
- }
+ clicked
}
/// The Share button's popover: the live session (start, invite link, stop or leave) and export.
@@ -685,17 +714,96 @@ pub fn join_field(app: &mut FigApp, ui: &mut Ui, typed: &mut String, id: egui::I
if focus {
resp.request_focus();
}
- let filled = !typed.trim().is_empty();
+ // One join at a time: the field waits while one is on its way.
+ let filled = !typed.trim().is_empty() && !join_pending(app);
let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let go = widgets::button(ui, tr("Join"), true, filled).clicked() || enter;
- let joined = if go && filled { app.run("collab.join", json!({"code": typed.trim()})) } else { None };
- let session = joined.and_then(|j| j["session"].as_u64());
+ let session = if go && filled { start_join(app, typed.trim()) } else { None };
if session.is_some() {
typed.clear();
}
session
}
+/// Join with `typed` (a code or an invite link); the Join dialog follows the join. Its session, if
+/// it started.
+fn start_join(app: &mut FigApp, typed: &str) -> Option {
+ let session = app.run("collab.join", json!({"code": typed})).and_then(|j| j["session"].as_u64())?;
+ app.ui.live.joining = Some((session, typed.to_string()));
+ Some(session)
+}
+
+/// A join started from a join field is on its way (not ready and not failed).
+fn join_pending(app: &FigApp) -> bool {
+ app.ui.live.joining.as_ref().and_then(|(id, _)| app.session.live.get(*id)).is_some_and(|l| !l.syncing() && l.status.active())
+}
+
+/// Call off the join the Join dialog follows (it may have failed: it is forgotten).
+fn cancel_join(app: &mut FigApp) {
+ if let Some((id, _)) = app.ui.live.joining.take() {
+ app.run("collab.leave", json!({"session": id}));
+ }
+}
+
+/// What the join the Join dialog follows is doing: `None` when there is none to show (its file
+/// is ready, and then shows, or it ended elsewhere); else why it failed, or `None` inside while
+/// it is on its way.
+fn join_progress(app: &mut FigApp) -> Option