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#"color(srgb 1 0 0) mycolor(1)"#; + let (out, _) = resolve(svg.as_bytes(), ColorProfile::Srgb).unwrap(); + assert_eq!( + String::from_utf8(out).unwrap(), + r##"color(srgb 1 0 0) mycolor(1)"## + ); +} + +#[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##"{defs}{body}"##) +} + +/// 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> { + let (id, _) = app.ui.live.joining.as_ref()?; + let Some(live) = app.session.live.get(*id) else { + app.ui.live.joining = None; + return None; + }; + if live.syncing() { + app.ui.live.joining = None; + app.ui.home = false; + return None; + } + Some(match &live.status { + Status::Failed { reason } => Some(failure_text(reason).into_owned()), + _ => None, + }) +} + +/// The join started from a join field, from the click until its file is ready: joining (Cancel +/// calls it off), or why it failed (Try again joins with the same code). Whether it showed. +fn joining_dialog(app: &mut FigApp, ctx: &egui::Context) -> bool { + let Some(failed) = join_progress(app) else { return false }; + let t = Tokens::get(ctx); + let (mut open, mut cancel, mut retry) = (true, false, false); + crate::dialogs::window(tr("Join live session"), ctx).id(egui::Id::unique("Join live session")).open(&mut open).show(ctx, |ui| { + ui.set_width(380.0); + match &failed { + None => { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(14.0)); + widgets::label(ui, tr("Joining live session…"), t.text); + }); + } + Some(reason) => widgets::label(ui, reason, t.danger), + } + ui.horizontal(|ui| { + retry = failed.is_some() && widgets::button(ui, tr("Try again"), true, true).clicked(); + cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); + }); + }); + if retry { + retry_join(app); + } else if cancel || !open { + cancel_join(app); + } + true +} + +/// Join again with what the failed join was started with; the failed one goes once the new one +/// is on its way. +fn retry_join(app: &mut FigApp) { + let Some((old, typed)) = app.ui.live.joining.clone() else { return }; + if start_join(app, &typed).is_some() { + app.run("collab.leave", json!({"session": old})); + } +} + /// What join `session` is doing, under the field it was started from (the home screen): on its /// way, or why it failed. pub fn join_status(app: &FigApp, ui: &mut Ui, t: &Tokens, session: SessionId) { @@ -714,28 +822,24 @@ pub(crate) fn failure_text(reason: &str) -> Cow<'_, str> { } /// Not in a session: your name, how to share (online or on the local network), start; or join -/// one with a code. Everything else is under Advanced. +/// one with a code. Everything else is under Advanced. The browser shares online only (a page +/// can't host a session). fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { - ui.horizontal(|ui| { - widgets::label(ui, tr("Your name"), t.text); - let name = app.session.prefs.user_name.clone(); - if let Some(n) = widgets::text_field(ui, egui::Id::unique("live_name"), &name, 160.0, tr("Your name")) { - app.run("pref.userName", json!({"name": n})); - } - }); - if !app.session.live.can_host() { - widgets::label(ui, tr("Sharing a file needs the desktop app; here you can join one."), t.text_secondary); + name_row(app, ui, t, egui::Id::unique("live_name")); + let server = app.session.prefs.publish_server.trim().to_string(); + let hosts = app.session.live.can_host(); + if !hosts && server.is_empty() { + widgets::label(ui, tr("Online sharing is off: no server is set in Preferences. Here you can join a shared file."), t.text_secondary); widgets::label(ui, tr("Join with a code"), t.text); join_row(app, ui, JOIN_WIDTH); widgets::label(ui, tr(BROWSER_NOTE), t.text_secondary); return; } // Without a server in Preferences only the local network is left. - let server = app.session.prefs.publish_server.trim().to_string(); - let local = app.ui.live.local || server.is_empty(); + let local = hosts && (app.ui.live.local || server.is_empty()); if server.is_empty() { widgets::label(ui, tr("Sharing on the local network (online sharing is off: no server is set in Preferences)."), t.text_secondary); - } else if let Some(i) = widgets::segmented_text(ui, &[tr("Online"), tr("Local network")], usize::from(local)) { + } else if hosts && let Some(i) = widgets::segmented_text(ui, &[tr("Online"), tr("Local network")], usize::from(local)) { app.ui.live.local = i == 1; } if local { @@ -744,54 +848,83 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { online_note(app, ui, t, &server); } if widgets::button(ui, tr("Start sharing"), true, true).clicked() { - let mut params = json!({"via": if local { "local" } else { "online" }}); - if local && app.ui.live.this_computer { - params["bind"] = json!("local"); - } - if let (true, Ok(port)) = (local, app.ui.live.port.trim().parse::()) { - params["port"] = json!(port); - } - // The key goes out only to the server it belongs to (the server in Preferences): changing - // the server must not send the old server's key to a new one. - if !local - && let Some((at, key)) = app.ui.live.share_key.as_ref() - && *at == server - && !key.is_empty() - { - params["key"] = json!(key); - } - app.run("collab.share", params); + start_sharing(app, local, &server); } - join_and_advanced(app, ui, t, |app, ui, t| { - if local { - if widgets::checkbox(ui, app.ui.live.this_computer, tr("This computer only")) { - app.ui.live.this_computer = !app.ui.live.this_computer; - } - ui.horizontal(|ui| { - widgets::label(ui, tr("Port"), t.text); - if let Some(port) = widgets::text_field_named(ui, egui::Id::unique("live_port"), &app.ui.live.port, 70.0, tr("Port")) { - // Digits only; empty: the usual port, or any free one. - app.ui.live.port = port.trim().parse::().map(|p| p.to_string()).unwrap_or_default(); - } - }); - } else { - widgets::label(ui, tr("Another server, or one that needs its key. The usual server is set in Preferences."), t.text_secondary); - // The server key (a server that needs one; the address in Preferences is without it). - // It is remembered for the server it was typed for, so it never leaks to another one. - ui.horizontal(|ui| { - widgets::label(ui, tr("Server key"), t.text); - let shown = app.ui.live.share_key.as_ref().and_then(|(at, key)| (*at == server).then_some(key.as_str())).unwrap_or_default(); - if let Some(key) = widgets::secret_field_named(ui, egui::Id::unique("live_share_key"), shown, 200.0, tr("Server key")) { - let key: String = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect(); - app.ui.live.share_key = if key.is_empty() { None } else { Some((server.trim().to_string(), key)) }; - } - }); - if widgets::button(ui, tr("Publish to another server…"), false, true).clicked() { - open_publish(app); - ui.close(); + join_and_advanced(app, ui, t, |app, ui, t| share_advanced(app, ui, t, local, &server)); +} + +/// Start sharing the open file on the local network, or online on `server` (the one in +/// Preferences). +fn start_sharing(app: &mut FigApp, local: bool, server: &str) { + let mut params = json!({"via": if local { "local" } else { "online" }}); + if local && app.ui.live.this_computer { + params["bind"] = json!("local"); + } + if let (true, Ok(port)) = (local, app.ui.live.port.trim().parse::()) { + params["port"] = json!(port); + } + // The key goes out only to the server it belongs to (the server in Preferences): changing + // the server must not send the old server's key to a new one. + if !local + && let Some((at, key)) = app.ui.live.share_key.as_ref() + && at == server + && !key.is_empty() + { + params["key"] = json!(key); + } + app.run("collab.share", params); +} + +/// The Advanced part of starting a share: where on this computer (the local network), or the +/// server's key and another server (online). +fn share_advanced(app: &mut FigApp, ui: &mut Ui, t: &Tokens, local: bool, server: &str) { + if local { + if widgets::checkbox(ui, app.ui.live.this_computer, tr("This computer only")) { + app.ui.live.this_computer = !app.ui.live.this_computer; + } + ui.horizontal(|ui| { + widgets::label(ui, tr("Port"), t.text); + if let Some(port) = widgets::text_field_named(ui, egui::Id::unique("live_port"), &app.ui.live.port, 70.0, tr("Port")) { + // Digits only; empty: the usual port, or any free one. + app.ui.live.port = port.trim().parse::().map(|p| p.to_string()).unwrap_or_default(); } + }); + return; + } + widgets::label(ui, tr("Another server, or one that needs its key. The usual server is set in Preferences."), t.text_secondary); + // The server key (a server that needs one; the address in Preferences is without it). + // It is remembered for the server it was typed for, so it never leaks to another one. + ui.horizontal(|ui| { + widgets::label(ui, tr("Server key"), t.text); + let shown = app.ui.live.share_key.as_ref().and_then(|(at, key)| (at == server).then_some(key.as_str())).unwrap_or_default(); + if let Some(key) = widgets::secret_field_named(ui, egui::Id::unique("live_share_key"), shown, 200.0, tr("Server key")) { + let key: String = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect(); + app.ui.live.share_key = if key.is_empty() { None } else { Some((server.trim().to_string(), key)) }; } }); + if widgets::button(ui, tr("Publish to another server…"), false, true).clicked() { + open_publish(app); + ui.close(); + } +} + +/// "Your name" and its field (the Share popover, the Join dialog). +fn name_row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, id: egui::Id) { + ui.horizontal(|ui| { + widgets::label(ui, tr("Your name"), t.text); + name_field(app, ui, id, 160.0); + }); +} + +/// The field for your name (`pref.userName`), the one name comments are signed with and others +/// see you as in live sessions: empty while it is the default. A new name is kept at once (the +/// browser has no quitting to keep it at). +pub fn name_field(app: &mut FigApp, ui: &mut Ui, id: egui::Id, width: f32) { + let shown = figcraft_engine::live::display_name(&app.session.prefs); + if let Some(name) = widgets::text_field(ui, id, &shown, width, tr("Your name")) { + app.run("pref.userName", json!({"name": name})); + app.services.save_prefs(&app.session.prefs); + } } /// Under what starts a share: the join field, then the Advanced part (`advanced` draws it while @@ -1089,20 +1222,27 @@ pub fn open_invite(app: &mut FigApp, link: &str) { open_join(app, link.trim()); } -/// Escape closes the Join or Publish dialog (before the canvas's shortcuts see it: it doesn't -/// deselect). +/// Escape closes the Join or Publish dialog, and calls off the join the Join dialog follows +/// (before the canvas's shortcuts see it: it doesn't deselect). pub fn dialog_keys(app: &mut FigApp, ctx: &egui::Context) { - let live = &mut app.ui.live; - if (live.join.is_some() || live.publish.is_some()) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { - live.join = None; - live.publish = None; + let live = &app.ui.live; + if (live.join.is_some() || live.publish.is_some() || live.joining.is_some()) + && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + app.ui.live.join = None; + app.ui.live.publish = None; + cancel_join(app); } } -/// File ▸ Join live session: type a join code or paste an invite link. +/// File ▸ Join live session: type a join code or paste an invite link; then the join, until its +/// file is ready. pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) { // The editor takes Escape before its shortcuts; the home screen has none. dialog_keys(app, ctx); + if joining_dialog(app, ctx) { + return; + } let Some(mut link) = app.ui.live.join.take() else { return }; let t = Tokens::get(ctx); let mut open = true; @@ -1116,6 +1256,7 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) { go = join_field(app, ui, &mut link, egui::Id::unique("live_join_link"), 300.0, focus).is_some(); cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); }); + name_row(app, ui, &t, egui::Id::unique("live_join_name")); widgets::label(ui, tr("You'll get the shared file as a new tab and edit it together."), t.text_secondary); if !app.session.live.can_host() { widgets::label(ui, tr(BROWSER_NOTE), t.text_secondary); @@ -1132,10 +1273,6 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) { /// Open the Publish to server dialog for the open file, with the last address used this run in /// it (the server in Preferences is what Share ▸ Online uses: this dialog is for another one). pub fn open_publish(app: &mut FigApp) { - if !app.session.live.can_host() { - app.toast(tr("Publishing to a server needs the desktop app; in the browser you can join a server's files")); - return; - } let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default(); app.ui.live.publish = Some(PublishForm { server: app.ui.live.last_server.clone(), name, info: None }); app.ui.live.focus_publish = true; @@ -1273,12 +1410,14 @@ fn published_dialog(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let mut open = true; let mut done = false; + // The browser keeps only your name across reloads, so nothing remembers the token there. + let note = if app.session.live.can_host() { OWNER_NOTE } else { BROWSER_OWNER_NOTE }; crate::dialogs::window(tr("File published"), ctx).id(egui::Id::unique("File published")).open(&mut open).show(ctx, |ui| { ui.set_width(400.0); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); widgets::label(ui, &trf("{name} is on the server.", &[("name", &p.name)]), t.text); link_rows(app, ui, &[(tr("Owner token"), &owner, tr("Owner token copied"))]); - widgets::label(ui, tr(OWNER_NOTE), t.danger); + widgets::label(ui, tr(note), t.danger); link_rows(app, ui, &[(tr("Edit link"), &p.edit, tr("Edit link copied")), (tr("View link"), &p.view, tr("View link copied"))]); expiry_line(ui, &t, p.expires); if let Some(h) = &hosting { diff --git a/crates/ui-egui/src/live/agent.rs b/crates/ui-egui/src/live/agent.rs new file mode 100644 index 00000000..b962eccd --- /dev/null +++ b/crates/ui-egui/src/live/agent.rs @@ -0,0 +1,101 @@ +//! An AI agent attached over MCP, shown like a person in a live session while it works: its avatar +//! in the people row and, on the canvas, an outline in its colour around the layers it last edited +//! or selected, with its name. Both go a short while after its last command +//! ([`figcraft_engine::agent`]), and only the file it works in shows them. + +use egui::{Align2, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2}; +use figcraft_engine::agent::AgentPresence; +use web_time::Instant; + +use crate::FigApp; +use crate::a11y::Tip; +use crate::theme::{AGENT, Tokens}; + +/// The agent at work in the file in front; asks for the frame where it goes. +pub(crate) fn shown<'a>(app: &'a FigApp, ctx: &egui::Context) -> Option<&'a AgentPresence> { + let now = Instant::now(); + let p = app.session.agent_presence_at(now).filter(|p| app.session.ds().is_ok_and(|d| d.uid == p.doc))?; + ctx.request_repaint_after(p.left(now)); + Some(p) +} + +/// Its avatar at `c` in the people row: ringed in its colour while it works, its name as the tip. +pub(super) fn avatar(ui: &mut Ui, c: Pos2, r: f32, name: &str) { + let t = Tokens::get(ui.ctx()); + ui.interact(Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r)), ui.scope_id().with("live_agent"), Sense::hover()).tip(name); + ui.painter().circle_stroke(c, r + 1.0, Stroke::new(2.0, t.panel)); + super::avatar(ui.painter(), c, r, name, AGENT); + ui.painter().circle_stroke(c, r + 2.5, Stroke::new(2.0, AGENT)); +} + +/// Its layers on the page in front outlined in its colour, its name over them. +pub fn draw(app: &FigApp, painter: &egui::Painter, ctx: &egui::Context) { + let Some(p) = shown(app, ctx) else { return }; + let Ok(ds) = app.session.ds() else { return }; + let (doc, page) = (&ds.doc, ds.page); + let mut over: Option = None; + for q in p.layers.iter().filter(|n| doc.page_of(**n) == Some(page)).filter_map(|n| crate::canvas::node_quad(app, doc, *n)) { + painter.add(Shape::closed_line(q.to_vec(), Stroke::new(1.5, AGENT))); + let r = Rect::from_points(&q); + over = Some(over.map_or(r, |o| o.union(r))); + } + if let Some(r) = over { + super::name_tag(painter, pos2(r.min.x, r.min.y - 4.0), Align2::LEFT_BOTTOM, &p.name, AGENT); + } +} + +#[cfg(test)] +mod tests { + use figcraft_engine::Session; + use serde_json::json; + + use super::shown; + use crate::present_tests::Ui; + + fn rect(u: &mut Ui, agent: Option<&str>) { + let mut p = json!({"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 40, "height": 40}}); + if let Some(a) = agent { + p["agent"] = json!(a); + } + let ctx = u.ctx.clone(); + crate::control::handle(&mut u.app, &ctx, "engine.execute", &p).unwrap(); + u.frames(2, Vec::new); + } + + fn labels(u: &mut Ui) -> Vec<(String, egui::Pos2)> { + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.map(|t| t.nodes).unwrap_or_default(); + nodes + .into_iter() + .filter_map(|(_, n)| { + let b = n.bounds()?; + Some((n.label()?.to_string(), egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32))) + }) + .collect() + } + + #[test] + fn the_people_menu_still_lists_you_beside_an_agent() { + let mut u = Ui::new(Session::new()); + u.ctx.enable_accesskit(); + rect(&mut u, Some("Claude")); + let (_, at) = labels(&mut u).into_iter().find(|(l, _)| l == "People in this file").expect("the people row's menu"); + u.button(at, true); + u.button(at, false); + u.frames(2, Vec::new); + let shown = labels(&mut u); + assert!(shown.iter().any(|(l, _)| l.ends_with("(you)")), "{shown:?}"); + } + + #[test] + fn an_attached_agent_shows_in_the_file_it_works_in() { + let mut u = Ui::new(Session::new()); + rect(&mut u, None); + assert!(shown(&u.app, &u.ctx).is_none(), "the control channel's own calls are the user's"); + rect(&mut u, Some("Claude")); + let p = shown(&u.app, &u.ctx).expect("the agent shows"); + assert_eq!((p.name.as_str(), p.layers.len()), ("Claude (agent)", 1)); + u.run("file.new", json!({})); + assert!(shown(&u.app, &u.ctx).is_none(), "another file in front shows no agent"); + } +} diff --git a/crates/ui-egui/src/live_join_tests.rs b/crates/ui-egui/src/live_join_tests.rs index bc1db8ad..018a6317 100644 --- a/crates/ui-egui/src/live_join_tests.rs +++ b/crates/ui-egui/src/live_join_tests.rs @@ -3,7 +3,7 @@ //! (it joins but can't host). use egui::{Event, Key}; -use figcraft_engine::live::{Event as LiveEvent, HostOptions, LiveLink, Presence, SessionInfo, Status}; +use figcraft_engine::live::{Event as LiveEvent, HostOptions, LiveLink, Presence, PublishOptions, SessionInfo, Status}; use serde_json::json; use crate::live_tests::{Harness, has_text, text_center}; @@ -58,7 +58,8 @@ fn opening_the_app_with_an_invite_link_asks_before_joining() { assert_eq!(h.app.session.live().status, Status::Off, "nothing is joined until the person confirms"); } -/// The browser's link: joins, can't host; joining fails here to show the dialog path. +/// The browser's link: joins and publishes, can't host; joining fails here to show the dialog +/// path. struct Browser; impl LiveLink for Browser { @@ -71,6 +72,9 @@ impl LiveLink for Browser { fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> { Err("that's not a live session invite".into()) } + fn publish(&mut self, _: u64, _: PublishOptions) -> Result<(), String> { + Ok(()) + } fn leave(&mut self, _: u64) {} fn presence(&mut self, _: u64, _: &Presence) {} fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {} @@ -91,20 +95,33 @@ fn a_link_that_cant_be_used_opens_the_dialog_with_it() { } #[test] -fn the_browser_joins_but_does_not_host() { +fn the_browser_shares_through_a_server_but_does_not_host() { let (mut h, _) = Harness::new(); h.app.session.live.install(Box::new(Browser)); assert!(!h.app.session.live.can_host()); assert_eq!(h.app.session.execute("collab.peers", &json!({})).unwrap()["canHost"], false); + // Without a server there is nothing to share through: the join field is right there, with + // what the browser can reach. + h.app.session.execute("pref.publishServer", &json!({"url": ""})).unwrap(); let shapes = h.frames(3); h.click(text_center(&shapes, "Share")); let shapes = h.frames(2); - assert!(has_text(&shapes, "Sharing a file needs the desktop app; here you can join one.")); + assert!(has_text(&shapes, "Online sharing is off: no server is set in Preferences. Here you can join a shared file.")); assert!(!has_text(&shapes, "Start sharing") && !has_text(&shapes, "Local network")); - // The join field is right there, with what the browser can reach. assert!(has_text(&shapes, "Join with a code") && has_text(&shapes, "Join")); assert!(has_text( &shapes, "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)." )); + // With one, it shares online only (the local network needs a page that can listen). + h.app.session.execute("pref.publishServer", &json!({"url": "ws://127.0.0.1:47910/"})).unwrap(); + let shapes = h.frames(2); + assert!(!has_text(&shapes, "Local network") && !has_text(&shapes, "Online")); + h.click(text_center(&shapes, "Start sharing")); + h.frames(1); + let live = h.app.session.live(); + assert_eq!((live.status.clone(), live.server.as_str()), (Status::Connecting, "ws://127.0.0.1:47910/"), "publishing to the server"); + // Publish to server… opens its dialog here too. + crate::menus::invoke(&mut h.app, "collab.publish"); + assert!(h.app.ui.live.publish.is_some()); } diff --git a/crates/ui-egui/src/live_joining_tests.rs b/crates/ui-egui/src/live_joining_tests.rs new file mode 100644 index 00000000..a5d46b8e --- /dev/null +++ b/crates/ui-egui/src/live_joining_tests.rs @@ -0,0 +1,99 @@ +//! The Join dialog follows a join from the click until its file is ready: joining (with Cancel), +//! then the file in a tab of its own; or why it failed, with Try again (#1381). + +use egui::{Event, Key}; +use figcraft_engine::live::{Event as LiveEvent, Status}; +use serde_json::json; + +use crate::live_tests::{Harness, has_text, text_center}; + +const LINK: &str = "figcraft://join?host=127.0.0.1:47900&file=f&token=t"; + +/// Join from the Join dialog with the invite link: the session it started. +fn join(h: &mut Harness) -> u64 { + crate::live::open_invite(&mut h.app, LINK); + // A new window measures itself first. + let shapes = h.frames(3); + h.click(text_center(&shapes, "Join")); + let shapes = h.frames(1); + assert!(has_text(&shapes, "Joining live session…") && has_text(&shapes, "Cancel"), "the dialog shows the join at once"); + let (id, typed) = h.app.ui.live.joining.clone().unwrap(); + assert_eq!(typed, LINK); + id +} + +fn joining_shown(h: &mut Harness) -> bool { + has_text(&h.frames(2), "Joining live session…") +} + +#[test] +fn joining_shows_until_the_file_is_ready() { + let (mut h, _) = Harness::new(); + let id = join(&mut h); + // One join at a time: the home screen's field waits. + h.app.ui.home = true; + h.app.ui.live.code = LINK.into(); + let shapes = h.frames(2); + h.click(text_center(&shapes, "Join")); + assert_eq!(h.app.ui.live.joining.as_ref().map(|j| j.0), Some(id), "no second join"); + assert_eq!(h.app.session.live.iter().count(), 1); + // Welcomed, but the file isn't here yet: still joining. + h.welcome(); + assert!(joining_shown(&mut h)); + h.sync(); + assert!(!joining_shown(&mut h) && h.app.ui.live.joining.is_none(), "it goes once the file is ready"); + assert!(!h.app.ui.home, "and the joined file shows"); + assert_eq!(h.app.session.docs.len(), 2); +} + +#[test] +fn a_failed_join_says_why_and_tries_again() { + let (mut h, _) = Harness::new(); + let first = join(&mut h); + h.send(vec![LiveEvent::Status(Status::Failed { reason: "Couldn't reach 127.0.0.1:47900".into() })]); + let shapes = h.frames(2); + assert!(has_text(&shapes, "Couldn't reach 127.0.0.1:47900") && has_text(&shapes, "Try again")); + assert!(!has_text(&shapes, "Joining live session…")); + h.click(text_center(&shapes, "Try again")); + let second = h.app.ui.live.joining.as_ref().map(|j| j.0).unwrap(); + assert_ne!(second, first, "a new join with the same link"); + assert!(h.app.session.live.get(first).is_none(), "the failed one is gone"); + assert_eq!(h.app.session.live.get(second).map(|l| l.status.clone()), Some(Status::Connecting)); + assert!(joining_shown(&mut h)); + h.welcome(); + h.sync(); + assert!(h.app.ui.live.joining.is_none() && h.app.session.live.get(second).is_some_and(|l| l.syncing())); +} + +#[test] +fn cancel_and_escape_call_the_join_off() { + let (mut h, _) = Harness::new(); + let id = join(&mut h); + let shapes = h.frames(1); + h.click(text_center(&shapes, "Cancel")); + assert!(h.app.ui.live.joining.is_none() && h.app.session.live.get(id).is_none(), "the join ended"); + assert_eq!(h.app.session.live().status, Status::Off); + assert!(!joining_shown(&mut h)); + // Escape is Cancel, and so is a failed join's Cancel (it is forgotten). + let id = join(&mut h); + for pressed in [true, false] { + h.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }]); + } + assert!(h.app.ui.live.joining.is_none() && h.app.session.live.get(id).is_none()); + let id = join(&mut h); + h.send(vec![LiveEvent::Status(Status::Failed { reason: "No session with that code".into() })]); + let shapes = h.frames(2); + h.click(text_center(&shapes, "Cancel")); + assert!(h.app.ui.live.joining.is_none() && h.app.session.live.get(id).is_none()); + assert!(!has_text(&h.frames(2), "Try again")); +} + +#[test] +fn leave_takes_a_session_id() { + let (mut h, _) = Harness::new(); + let id = h.app.session.execute("collab.join", &json!({"link": LINK})).unwrap()["session"].as_u64().unwrap(); + assert!(h.app.session.execute("collab.leave", &json!({"session": "one"})).is_err()); + assert_eq!(h.app.session.execute("collab.leave", &json!({"session": id + 7})).unwrap()["ended"], false, "an unknown one is no error"); + assert_eq!(h.app.session.execute("collab.leave", &json!({"session": id})).unwrap()["ended"], true); + assert!(h.app.session.live.get(id).is_none()); +} diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index a2ac63c2..f2fbe11a 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -450,8 +450,8 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() { key(&mut h, Key::Enter); assert_eq!(script.lock().unwrap().joined, ["figcraft://join?server=wss://share.example/&code=K7M2QX9TD"]); let shapes = h.frames(2); - assert!(has_text(&shapes, "Joining…") && h.app.ui.home); - // It failed: the reason shows under the field, and the home screen stays. + assert!(has_text(&shapes, "Joining live session…") && h.app.ui.home, "the Join dialog shows the join"); + // It failed: the dialog says why, and the home screen stays. script.lock().unwrap().events.push(LiveEvent::Status(Status::Failed { reason: "That code has expired or its sharing was stopped".into() })); let shapes = h.frames(3); assert!(has_text(&shapes, "That code has expired or its sharing was stopped") && h.app.ui.home); @@ -467,8 +467,8 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() { assert_eq!(h.app.session.docs.len(), 2); } -/// Asserts the join dialog uses the same shared join field, focuses it, and closes once the -/// join starts. +/// Asserts the join dialog uses the same shared join field, focuses it, and shows the join once +/// it starts. #[test] fn the_join_dialog_is_the_same_field() { let (mut h, script) = harness(); @@ -480,7 +480,8 @@ fn the_join_dialog_is_the_same_field() { key(&mut h, Key::Enter); h.frames(1); assert_eq!(script.lock().unwrap().joined, ["figcraft://join?server=wss://share.example/&code=4FHJ8R2WN"]); - assert!(h.app.ui.live.join.is_none(), "it closes once the join started"); + assert!(h.app.ui.live.join.is_none(), "its field goes once the join started"); + assert!(has_text(&h.frames(1), "Joining live session…"), "and it shows the join"); } #[test] diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index 63c3c42a..cd8454af 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -114,6 +114,11 @@ impl Harness { /// Joined as Ada, with Hana (the host) already here. pub(crate) fn joined(&mut self) { self.app.session.execute("collab.join", &json!({"link": "figcraft://join?host=127.0.0.1:47900&file=f&token=t"})).unwrap(); + self.welcome(); + } + + /// The host's welcome for the join started last: you are Ada, with Hana (the host) here. + pub(crate) fn welcome(&mut self) { let info = SessionInfo { role: Role::Guest, me: 2, diff --git a/crates/ui-egui/src/local_fonts_tests.rs b/crates/ui-egui/src/local_fonts_tests.rs new file mode 100644 index 00000000..facd853f --- /dev/null +++ b/crates/ui-egui/src/local_fonts_tests.rs @@ -0,0 +1,67 @@ +//! #1051: where the platform can list the fonts installed on this computer only when asked (the +//! browser's Local Font Access), the font menu offers "Use fonts installed on this computer…"; +//! clicking it asks the platform once. Elsewhere the row isn't there. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use egui::{Event, Key}; +use serde_json::json; + +use crate::Services; +use crate::live_tests::{Harness, has_text, text_center}; +use crate::present_tests::id; + +const ROW: &str = "Use fonts installed on this computer…"; + +/// A browser that can list the installed fonts: counts how often it is asked. +struct Lists(Arc); + +impl Services for Lists { + fn can_ask_for_local_fonts(&self) -> bool { + self.0.load(Ordering::SeqCst) == 0 + } + fn ask_for_local_fonts(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +/// The font menu of a selected text layer, opened: what it shows. +fn font_menu(h: &mut Harness) -> Vec { + let family = h.app.session.ds().ok().and_then(|ds| ds.selection.first().copied()).and_then(|t| { + let doc = h.app.session.doc().ok()?; + match &doc.node(t)?.kind { + figcraft_doc::NodeKind::Text(td) => Some(td.style.font_family.clone()), + _ => None, + } + }); + let family = family.unwrap_or_else(|| panic!("no text layer selected")); + let shapes = h.frames(3); + h.click(text_center(&shapes, &family)); + h.frames(2) +} + +fn escape(h: &mut Harness) { + for pressed in [true, false] { + h.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }]); + } +} + +#[test] +fn the_font_menu_offers_the_fonts_on_this_computer_where_they_must_be_asked_for() { + let (mut h, _) = Harness::new(); + let t = id(&h.app.session.execute("text.create", &json!({"text": "Hello", "x": 100, "y": 300})).unwrap()); + h.app.session.execute("select.set", &json!({"ids": [t]})).unwrap(); + // The desktop sees its fonts anyway. + assert!(!has_text(&font_menu(&mut h), ROW)); + escape(&mut h); + let asked = Arc::new(AtomicUsize::new(0)); + h.app.services = Box::new(Lists(asked.clone())); + let shapes = font_menu(&mut h); + assert!(has_text(&shapes, ROW)); + h.click(text_center(&shapes, ROW)); + h.frames(2); + assert_eq!(asked.load(Ordering::SeqCst), 1, "asked once"); + // Once asked, the menu lists the fonts and doesn't offer the row again. + assert!(!has_text(&font_menu(&mut h), ROW)); +} diff --git a/crates/ui-egui/src/make_slot_naming_tests.rs b/crates/ui-egui/src/make_slot_naming_tests.rs new file mode 100644 index 00000000..b8028e97 --- /dev/null +++ b/crates/ui-egui/src/make_slot_naming_tests.rs @@ -0,0 +1,83 @@ +//! Make slot asks for the slot's name first, driven through egui headless (#1537): picking it in +//! the Slot menu of a frame inside a component shows a name field with the frame's name selected; +//! what is typed names the slot on Enter, and Escape makes nothing. + +use egui::Event; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use figcraft_engine::components::slot_name; +use serde_json::json; + +use crate::component_swap_default_tests::{centre, click, tree}; +use crate::layers_drag_tests::Ui; + +/// A "Card" component holding a selected frame "Frame 12". Returns (ui, frame). +fn setup() -> (Ui, NodeId) { + let mut s = Session::new(); + let id = |v: serde_json::Value| NodeId(v["id"].as_u64().unwrap()); + let card = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Card"})).unwrap()); + let frame = id(s.execute("frame.create", &json!({"x": 10, "y": 50, "width": 200, "height": 100, "parent": card, "name": "Frame 12"})).unwrap()); + s.execute("component.create", &json!({"ids": [card]})).unwrap(); + s.execute("select.set", &json!({"ids": [frame]})).unwrap(); + let u = Ui::new(s); + u.ctx.enable_accesskit(); + (u, frame) +} + +fn key(key: egui::Key) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() } +} + +/// Open the frame's Slot menu and pick Make slot; returns the name field's value then. +fn pick_make_slot(u: &mut Ui) -> Option { + let combo = tree(u).into_iter().find(|n| n.label() == Some("Slot") && n.value() == Some("Not bound")).expect("the frame has a Slot row"); + click(u, centre(&combo)); + let item = tree(u).into_iter().find(|n| n.label() == Some("Make slot")).expect("the Slot menu offers Make slot"); + click(u, centre(&item)); + u.frame(vec![]); + u.frame(vec![]); + tree(u).into_iter().find(|n| n.label() == Some("Slot name")).and_then(|n| n.value().map(str::to_string)) +} + +fn slot_of(u: &Ui, frame: NodeId) -> Option { + slot_name(u.app.session.doc().unwrap(), frame) +} + +#[test] +fn make_slot_names_the_slot_with_what_is_typed() { + let (mut u, frame) = setup(); + assert_eq!(pick_make_slot(&mut u).as_deref(), Some("Frame 12"), "the field suggests the frame's name"); + assert_eq!(slot_of(&u, frame), None, "nothing is made before the name is confirmed"); + // The suggestion is selected: typing replaces it. + u.frame(vec![Event::Text("Avatar".into())]); + u.frame(vec![key(egui::Key::Enter)]); + u.frame(vec![]); + assert_eq!(slot_of(&u, frame).as_deref(), Some("Avatar")); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); + assert!(tree(&mut u).iter().all(|n| n.label() != Some("Slot name")), "the naming row closes"); +} + +#[test] +fn escape_cancels_make_slot() { + let (mut u, frame) = setup(); + assert!(pick_make_slot(&mut u).is_some()); + u.frame(vec![key(egui::Key::Escape)]); + u.frame(vec![]); + assert_eq!(slot_of(&u, frame), None); + assert!(tree(&mut u).iter().all(|n| n.label() != Some("Slot name"))); + assert_eq!(u.app.session.ds().unwrap().selection, [frame], "the Escape only cancels the naming"); +} + +#[test] +fn a_name_taken_keeps_the_field_open() { + let (mut u, frame) = setup(); + let card = u.app.session.doc().unwrap().node(frame).and_then(|n| n.parent).unwrap(); + u.app.session.execute("component.addProperty", &json!({"id": card, "name": "Taken", "type": "boolean"})).unwrap(); + assert!(pick_make_slot(&mut u).is_some()); + u.frame(vec![Event::Text("Taken".into())]); + u.frame(vec![key(egui::Key::Enter)]); + u.frame(vec![]); + assert_eq!(slot_of(&u, frame), None); + let field = tree(&mut u).into_iter().find(|n| n.label() == Some("Slot name")).expect("the field stays to fix the name"); + assert_eq!(field.value(), Some("Taken")); +} diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index 50de5bf9..ac1afdd1 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -1,6 +1,6 @@ //! Adding and removing fill, effect and layout grid rows in the Design panel, driven through egui headless: -//! adding a paint opens its colour picker; an open colour picker and open settings stay with their items, -//! and go when their item goes. +//! adding a paint opens its colour picker beside the sidebar; outside presses dismiss it, while +//! open effect and grid settings stay with their items and go when their item goes. use egui::{Event, Id, Pos2}; use figcraft_doc::NodeId; @@ -70,10 +70,9 @@ fn flag(u: &Ui, salt: &'static str, i: usize) -> bool { u.ctx.data(|d| d.get_temp::(Id::unique((salt, i)))).unwrap_or(false) } -/// Asserts removing a fill keeps the open picker on the surviving paint (with its index -/// shifted), and removing the paint being edited closes the picker. +/// Removing a fill acts on the paint and dismisses the picker on the outside press. #[test] -fn removing_a_fill_keeps_the_picker_on_its_paint() { +fn removing_a_fill_dismisses_the_picker() { let mut s = Session::new(); let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); s.execute("fill.set", &json!({"ids": [r], "color": "#FF0000"})).unwrap(); @@ -82,7 +81,7 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { s.execute("select.set", &json!({"ids": [r]})).unwrap(); let mut u = Ui::new(s); u.ctx.enable_accesskit(); - // Green's swatch, then red's Remove: the picker follows green to index 0. + // Green's swatch, then red's Remove: the outside press dismisses the picker. let sw = u.ctx.read_response(Id::unique(("swatch", true, 1usize))).unwrap().rect.center(); click(&mut u, sw); assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 1, ids: vec![r] })); @@ -90,15 +89,15 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { click(&mut u, rm); let fills: Vec = u.app.session.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect(); assert_eq!(fills, ["00FF00", "0000FF"]); - assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![r] })); - // Removing the paint being edited closes the picker. + assert_eq!(u.app.ui.picker, None); + // Removing the surviving paint works with the picker dismissed. let rm = remove_button(&mut u, "fills", 0); click(&mut u, rm); assert_eq!(u.app.ui.picker, None); } -/// Asserts removing effects or grids moves open settings and an open colour picker to the -/// surviving row, or closes them when their own row is removed. +/// Removing effects or grids moves open settings to the surviving row; the outside press +/// dismisses the colour picker. Removing the open item closes its settings. #[test] fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { let mut s = Session::new(); @@ -118,7 +117,7 @@ fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { click(&mut u, rm); assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().effects.len(), 2); assert!(flag(&u, "fx_open", 1) && !flag(&u, "fx_open", 2)); - assert_eq!(u.app.ui.picker, Some(PickerTarget::Effect { ids: vec![f], index: 1, secondary: false })); + assert_eq!(u.app.ui.picker, None); // Removing the open effect closes its settings and the picker. let rm = remove_button(&mut u, "effects", 1); click(&mut u, rm); @@ -225,3 +224,51 @@ fn the_swatch_that_opened_the_colour_picker_closes_it_again() { click(&mut u, sw); assert_eq!(u.app.ui.picker, None, "the same swatch closes it again"); } + +/// Blank space in the sidebar is outside the picker too, including after adding a paint. +#[test] +fn added_paints_open_beside_the_sidebar_and_close_on_a_sidebar_press() { + for (label, fills) in [("Add fill", true), ("Add stroke", false)] { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"width": 50, "height": 50})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + let add = button(&mut u, label); + click(&mut u, add); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(matches!(u.app.ui.picker, Some(PickerTarget::Paint { fills: f, .. }) if f == fills)); + let pop = u.app.ui.picker_popover.as_ref().unwrap(); + let sidebar_left = u.app.ui.right_panel_rect.unwrap().min.x; + assert!((pop.rect.max.x - (sidebar_left - 8.0)).abs() < 1.0, "{:?}", pop.rect); + assert!(pop.rect.min.y <= add.y, "the picker aligns with its opener or lifts to fit"); + assert!(pop.rect.max.y <= 900.0 - 8.0); + u.button(Pos2::new(1420.0, 850.0), true); + assert!(u.app.ui.picker.is_none(), "a sidebar press dismisses the picker before release"); + } +} + +#[test] +fn page_colour_picker_closes_on_an_outside_press_and_reopens_at_its_control() { + let mut u = Ui::new(Session::new()); + let button = egui::Rect::from_min_size(Pos2::new(1230.0, 220.0), egui::vec2(16.0, 16.0)); + u.app.toggle_picker(PickerTarget::PageBackground, Some(button)); + for _ in 0..3 { + u.frame(vec![]); + } + let old = u.app.ui.picker_popover.take().unwrap().at(Pos2::new(300.0, 100.0)); + u.app.ui.picker_popover = Some(old); + u.frame(vec![]); + u.button(Pos2::new(1420.0, 850.0), true); + assert!(u.app.ui.picker.is_none()); + u.button(Pos2::new(1420.0, 850.0), false); + u.app.toggle_picker(PickerTarget::PageBackground, Some(button)); + for _ in 0..3 { + u.frame(vec![]); + } + let rect = u.app.ui.picker_popover.as_ref().unwrap().rect; + assert!((rect.max.x - (u.app.ui.right_panel_rect.unwrap().min.x - 8.0)).abs() < 1.0); + assert!((rect.min.y - u.app.ui.picker_button.unwrap().min.y).abs() < 1.0); +} diff --git a/crates/ui-egui/src/panels/assets_panel.rs b/crates/ui-egui/src/panels/assets_panel.rs index db0cc0f7..4fe5566d 100644 --- a/crates/ui-egui/src/panels/assets_panel.rs +++ b/crates/ui-egui/src/panels/assets_panel.rs @@ -171,7 +171,7 @@ fn drag_id() -> Id { /// Whether component `id` has the same layers in two versions of a document (layers an edit /// didn't touch stay shared between the versions, so this compares pointers, not contents). -fn same_layers(a: &Document, b: &Document, id: NodeId) -> bool { +pub(crate) fn same_layers(a: &Document, b: &Document, id: NodeId) -> bool { let mut stack = vec![id]; let mut seen = 0; while let Some(i) = stack.pop() { diff --git a/crates/ui-egui/src/panels/component_panel.rs b/crates/ui-egui/src/panels/component_panel.rs index 0ab3e5cc..ddd7e3d3 100644 --- a/crates/ui-egui/src/panels/component_panel.rs +++ b/crates/ui-egui/src/panels/component_panel.rs @@ -14,7 +14,7 @@ use egui::{Id, Sense, Ui, vec2}; use figcraft_doc::{Document, Node, NodeId, NodeKind, PropDef, VariableType}; use figcraft_engine::cmd::component::{can_add_variant, can_combine_variants}; use figcraft_engine::components::{ - PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_conflicts, + PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, prop_list, slot_def, slot_edited, slots_of, variant_conflicts, variant_options, variant_props, variant_set, }; use serde_json::{Value, json}; @@ -207,13 +207,6 @@ fn slot_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, prefer }); } -fn prop_defs(doc: &Document, c: NodeId) -> std::collections::BTreeMap { - match doc.node(c).map(|n| &n.kind) { - Some(NodeKind::Component { props, .. }) => props.clone(), - _ => Default::default(), - } -} - /// Properties of a component or component set. fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { let t = Tokens::get(ui.ctx()); @@ -224,11 +217,11 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } else { Some(owner) }; - let defs = first.map(|c| prop_defs(doc, c)).unwrap_or_default(); + let defs = first.map(|c| prop_list(doc, c)).unwrap_or_default(); let variants = if is_set { variant_options(doc, owner) } else { Vec::new() }; let comps = local_components(app, ui, doc); widgets::separator(ui); - let existing: Vec = defs.keys().cloned().chain(variants.iter().map(|(k, _)| k.clone())).collect(); + let existing: Vec = defs.iter().map(|(k, _)| k.clone()).chain(variants.iter().map(|(k, _)| k.clone())).collect(); let fresh = (1..).map(|k| format!("Property {k}")).find(|n| !existing.contains(n)).unwrap_or_else(|| "Property".into()); let plus = header_with_plus(ui, tr("Properties"), tr("Create component property")); menu_popup(&plus).width(180.0).show(|ui| { @@ -340,19 +333,10 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { let resp = widgets::dropdown(ui, &label, 84.0, None); crate::a11y::combo(&resp, k, &label, crate::a11y::menu_open(&resp)); // A file with many components (an icon library) scrolls instead of running off the screen. - menu_popup(&resp).width(200.0).show(|ui| { - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (c, name) in comps.iter() { - let current = c == default; - if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { - if !current { - app.run("component.editProperty", json!({"id": owner, "name": k, "default": c.0})); - } - ui.close(); - } - } - }); - }); + let all = || vec![(None, all_components(&comps))]; + if let Some(c) = component_menu(ui, &resp, 200.0, all, |c| c == *default).filter(|c| c != default) { + app.run("component.editProperty", json!({"id": owner, "name": k, "default": c.0})); + } } PropDef::Variant { .. } | PropDef::Slot { .. } => {} } @@ -405,9 +389,24 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { let t = Tokens::get(ui.ctx()); let Some(comp) = owning_component(doc, n.id) else { return }; - let defs = prop_defs(doc, comp); - // The English label also names a new property (document content); it shows translated. - let mut fields: Vec<(&str, &str, &str)> = vec![("visible", tr_noop("Visibility"), "boolean")]; + let defs = prop_list(doc, comp); + widgets::separator(ui); + row(ui, 36.0, |ui| { + ui.label(egui::RichText::new(tr("Component property")).font(semibold(11.0)).color(t.text)); + }); + for field in bind_fields(n) { + bind_row(app, ui, n, &defs, field); + } + slot_name_row(app, ui, n); +} + +/// A field of a layer a component property can control: (field, label, property type). The +/// English label also names a new property (document content); it shows translated. +type BindField = (&'static str, &'static str, &'static str); + +/// The fields of layer `n` that component properties can control. +fn bind_fields(n: &Node) -> Vec { + let mut fields: Vec = vec![("visible", tr_noop("Visibility"), "boolean")]; if n.kind.text().is_some() { fields.push(("characters", tr_noop("Text"), "text")); } @@ -417,71 +416,118 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { if matches!(n.kind, NodeKind::Frame) { fields.push(("slot", tr_noop("Slot"), "slot")); } - widgets::separator(ui); - row(ui, 36.0, |ui| { - ui.label(egui::RichText::new(tr("Component property")).font(semibold(11.0)).color(t.text)); + fields +} + +/// `base`, or `base 2`, `base 3`…: the first name no property in `defs` has. +fn fresh_name(base: &str, defs: &[(String, PropDef)]) -> String { + (1..).map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }).find(|x| !defs.iter().any(|(k, _)| k == x)).unwrap_or_default() +} + +/// The row of one field of layer `n`: its icon, its label and a menu that binds it to a property +/// of type `ty` (or makes one). +fn bind_row(app: &mut FigApp, ui: &mut Ui, n: &Node, defs: &[(String, PropDef)], field: BindField) { + let t = Tokens::get(ui.ctx()); + let (key, label, _) = field; + let bound = n.prop_refs.get(key).cloned(); + row(ui, 32.0, |ui| { + let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); + let icon = match key { + "visible" => Icon::Eye, + "characters" => Icon::Text, + "slot" => Icon::Slot, + _ => Icon::Instance, + }; + icons::paint(ui.painter(), ir, icon, if bound.is_some() { COMPONENT } else { t.icon_secondary }); + widgets::label(ui, tr(label), t.text_secondary); + let shown = bound.clone().unwrap_or_else(|| tr("Not bound").into()); + let resp = widgets::dropdown(ui, &shown, 120.0, None).on_hover_text(tr("Apply a component property")); + crate::a11y::combo(&resp, tr(label), &shown, crate::a11y::menu_open(&resp)); + menu_popup(&resp).width(180.0).show(|ui| bind_menu(app, ui, n, defs, field, bound.is_some())); }); - for (field, label, ty) in fields { - let matching: Vec<&String> = defs - .iter() - .filter(|(_, d)| { - matches!( - (ty, d), - ("boolean", PropDef::Boolean { .. }) - | ("text", PropDef::Text { .. }) - | ("instanceSwap", PropDef::InstanceSwap { .. }) - | ("slot", PropDef::Slot { .. }) - ) - }) - .map(|(k, _)| k) - .collect(); - let bound = n.prop_refs.get(field).cloned(); - row(ui, 32.0, |ui| { - let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); - let icon = match field { - "visible" => Icon::Eye, - "characters" => Icon::Text, - "slot" => Icon::Slot, - _ => Icon::Instance, - }; - icons::paint(ui.painter(), ir, icon, if bound.is_some() { COMPONENT } else { t.icon_secondary }); - widgets::label(ui, tr(label), t.text_secondary); - let shown = bound.clone().unwrap_or_else(|| tr("Not bound").into()); - let resp = widgets::dropdown(ui, &shown, 120.0, None).on_hover_text(tr("Apply a component property")); - menu_popup(&resp).width(180.0).show(|ui| { - for k in &matching { - if menu_item(ui, k, "") { - app.run("component.bindProperty", json!({"layer": n.id, "property": k, "field": field})); - ui.close(); - } - } - if ty == "slot" { - if bound.is_none() && menu_item(ui, tr("Make slot"), "") { - // Named after the frame. - app.run("component.makeSlot", json!({"id": n.id})); - ui.close(); - } - } else if menu_item(ui, tr("Create property…"), "") { - let existing: Vec = defs.keys().cloned().collect(); - let name = (1..) - .map(|k| if k == 1 { label.to_string() } else { format!("{label} {k}") }) - .find(|x| !existing.contains(x)) - .unwrap_or_default(); - app.run("component.addProperty", json!({"id": n.id, "name": name, "type": ty})); - ui.close(); - } - if ty == "slot" { - if bound.is_some() && menu_item(ui, tr("Remove slot"), "") { - app.run("component.removeSlot", json!({"id": n.id})); - ui.close(); - } - } else if bound.is_some() && menu_item(ui, tr("Detach property"), "") { - app.run("component.bindProperty", json!({"layer": n.id, "property": null, "field": field})); - ui.close(); - } - }); - }); +} + +/// The menu of a field's row ([`bind_row`]): the properties of its type, then making one (a slot +/// is named first, in [`slot_name_row`]) and unbinding (removing the slot). +fn bind_menu(app: &mut FigApp, ui: &mut Ui, n: &Node, defs: &[(String, PropDef)], (key, label, ty): BindField, bound: bool) { + let fits = |d: &PropDef| { + matches!( + (ty, d), + ("boolean", PropDef::Boolean { .. }) + | ("text", PropDef::Text { .. }) + | ("instanceSwap", PropDef::InstanceSwap { .. }) + | ("slot", PropDef::Slot { .. }) + ) + }; + for (k, _) in defs.iter().filter(|(_, d)| fits(d)) { + if menu_item(ui, k, "") { + app.run("component.bindProperty", json!({"layer": n.id, "property": k, "field": key})); + ui.close(); + } } + let slot = ty == "slot"; + if slot && !bound && menu_item(ui, tr("Make slot"), "") { + let base = if n.name.trim().is_empty() { label } else { n.name.as_str() }; + ui.data_mut(|d| d.insert_temp(slot_naming_id(n.id), (fresh_name(base, defs), true))); + ui.close(); + } + if !slot && menu_item(ui, tr("Create property…"), "") { + app.run("component.addProperty", json!({"id": n.id, "name": fresh_name(label, defs), "type": ty})); + ui.close(); + } + if slot && bound && menu_item(ui, tr("Remove slot"), "") { + app.run("component.removeSlot", json!({"id": n.id})); + ui.close(); + } + if !slot && bound && menu_item(ui, tr("Detach property"), "") { + app.run("component.bindProperty", json!({"layer": n.id, "property": null, "field": key})); + ui.close(); + } +} + +/// Where Make slot keeps the name being typed for frame `id` (and whether its field takes the +/// keyboard next frame). +fn slot_naming_id(id: NodeId) -> Id { + Id::unique(("make_slot_name", id)) +} + +/// Make slot's naming step, once it was picked for frame `n`: a name field that takes the keyboard +/// with the suggested name selected, and a Make slot button. Enter or the button makes the slot +/// with that name; Escape cancels. +fn slot_name_row(app: &mut FigApp, ui: &mut Ui, n: &Node) { + let id = slot_naming_id(n.id); + let Some((mut name, mut focus)) = ui.data(|d| d.get_temp::<(String, bool)>(id)) else { return }; + let mut open = !n.prop_refs.contains_key("slot"); + row(ui, 32.0, |ui| { + ui.add_space(22.0); + let w = (ui.available_width() - 96.0).max(60.0); + let field = id.with("field"); + // Asked for until it has it: the click that picked Make slot takes the focus away again. + if focus && !crate::a11y::field_focused(ui.ctx()) { + ui.memory_mut(|m| m.request_focus(field)); + widgets::select_all_next(ui, field, &name); + } + let r = widgets::text_input(ui, field, &mut name, w, tr("Slot name")); + crate::a11y::text(&r, tr("Slot name"), &name); + focus &= !r.has_focus(); + let ok = !name.trim().is_empty(); + let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let make = widgets::button(ui, tr("Make slot"), true, ok).clicked() || (enter && ok); + // A refused name (one taken) keeps the field open to fix it. + if make && app.run("component.makeSlot", json!({"id": n.id, "name": name.trim()})).is_some() { + open = false; + } + if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + open = false; + } + }); + ui.data_mut(|d| { + if open { + d.insert_temp(id, (name, focus)); + } else { + d.remove::<(String, bool)>(id); + } + }); } /// Overridden property groups of a layer inside an instance, each with a reset button. @@ -590,24 +636,10 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { row(ui, 32.0, |ui| { let w = (ui.available_width() - 32.0).max(80.0); let resp = widgets::dropdown(ui, &comp_name, w, Some(Icon::Instance)).on_hover_text(tr("Swap instance")); - menu_popup(&resp).width(w.max(200.0)).show(|ui| { - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - let mut page = None; - for (c, name) in comps.iter() { - let pg = doc.page_of(*c); - if pg != page { - page = pg; - let pname = pg.and_then(|p| doc.node(p)).map(|p| p.name.clone()).unwrap_or_default(); - ui.label(egui::RichText::new(pname).font(semibold(11.0)).color(t.text_secondary)); - } - let current = Some(*c) == set || *c == *component; - if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") && !current { - app.run("instance.swap", json!({"ids": [id], "component": c.0})); - ui.close(); - } - } - }); - }); + let current = |c: NodeId| Some(c) == set || c == *component; + if let Some(c) = component_menu(ui, &resp, w.max(200.0), || by_page(doc, &comps), current).filter(|c| !current(*c)) { + app.run("instance.swap", json!({"ids": [id], "component": c.0})); + } if missing { if icons::button(ui, Icon::Component, 24.0, false, tr("Restore main component")).clicked() { app.run("component.restoreMain", json!({"id": id})); @@ -690,7 +722,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps } } // Boolean, text and instance-swap properties. - for (k, d) in prop_defs(doc, *component) { + for (k, d) in prop_list(doc, *component) { let v = props.get(&k).cloned().unwrap_or_else(|| figcraft_engine::components::prop_default_value(&d)); row(ui, 32.0, |ui| { prop_label(ui, &k, type_icon(&d)); @@ -717,7 +749,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps }); } PropDef::InstanceSwap { preferred, .. } => { - if let Some(c) = swap_dropdown(ui, doc, figcraft_engine::params::id_of(&v), &preferred, comps, w, "").1 { + if let Some(c) = swap_dropdown(ui, doc, figcraft_engine::params::id_of(&v), &preferred, comps, w, &k).1 { app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}})); } } @@ -750,34 +782,162 @@ pub(crate) fn swap_dropdown( if !name.is_empty() { crate::a11y::combo(&resp, name, &label, crate::a11y::menu_open(&resp)); } + let groups = || { + let preferred: Vec<(NodeId, &str)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.as_str()))).collect(); + if preferred.is_empty() { + vec![(None, all_components(comps))] + } else { + vec![(Some(tr("Preferred")), preferred), (Some(tr("All components")), all_components(comps))] + } + }; + // Picking the current component picks nothing. + let picked = component_menu(ui, &resp, 200.0, groups, |c| cur == Some(c)).filter(|c| cur != Some(*c)); + (resp, picked) +} + +/// A component menu's groups: a heading (or none) and its components (id, name). +type CompGroups<'a> = Vec<(Option<&'a str>, Vec<(NodeId, &'a str)>)>; + +/// `comps` under their pages' names (they come page by page). +fn by_page<'a>(doc: &'a Document, comps: &'a [(NodeId, String)]) -> CompGroups<'a> { + let mut groups: CompGroups = Vec::new(); + let mut page = None; + for (c, name) in comps { + let pg = doc.page_of(*c); + if groups.is_empty() || pg != page { + page = pg; + groups.push((pg.and_then(|p| doc.node(p)).map(|p| p.name.as_str()), Vec::new())); + } + if let Some((_, cs)) = groups.last_mut() { + cs.push((*c, name.as_str())); + } + } + groups +} + +/// `comps` as one group's components. +fn all_components(comps: &[(NodeId, String)]) -> Vec<(NodeId, &str)> { + comps.iter().map(|(c, n)| (*c, n.as_str())).collect() +} + +/// The tallest a component menu is, its search field included; its list scrolls past that. +const COMP_MENU_HEIGHT: f32 = 360.0; + +/// What a component menu's search found this frame. +struct CompSearch<'a> { + /// The groups with the components whose names match (groups left empty dropped). + groups: CompGroups<'a>, + /// The match Up and Down are on (counted across the groups), and whether they moved it now. + hi: Option, + moved: bool, + /// The component Enter picked. + entered: Option, +} + +/// The menu `field` opens, at least `width` wide, listing `groups` (made only while it is open) +/// under a search field that takes the keyboard when the menu opens and filters the components by +/// name as you type. Up and Down step through the matches and Enter picks the one they are on (or +/// the first); Escape clears the search, then closes the menu. `checked` components are ticked. +/// The component picked. +fn component_menu<'a>( + ui: &Ui, + field: &egui::Response, + width: f32, + groups: impl FnOnce() -> CompGroups<'a>, + checked: impl Fn(NodeId) -> bool, +) -> Option { + let opened = field.clicked(); + craft_ui::combo::Menu::new(ui, field) + .height(COMP_MENU_HEIGHT) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .style(crate::right::menu_style) + .show_ui_with_header( + |ui| comp_search(ui, field.id, width, opened, groups()), + |ui, found| found.entered.or_else(|| comp_rows(ui, &found, &checked)), + ) + .flatten() +} + +/// A component menu's search field over `groups`, and what it matches. +fn comp_search<'a>(ui: &mut Ui, field: Id, width: f32, opened: bool, groups: CompGroups<'a>) -> CompSearch<'a> { + ui.set_min_width(width); + let state = field.with("comp_search"); + let (mut q, mut hi): (String, Option) = if opened { Default::default() } else { ui.data(|d| d.get_temp(state)).unwrap_or_default() }; + let before = q.clone(); + // Taken before the search field sees them (it would move its cursor, or give up the keyboard). + let (step, enter) = ui.input_mut(|i| { + if !q.is_empty() && i.consume_key(egui::Modifiers::NONE, egui::Key::Escape) { + q.clear(); + } + let down = i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64; + (down - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64, i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) + }); + let r = ui.add( + egui::TextEdit::singleline(&mut q) + .id(field.with("comp_query")) + // Escape clears the search (above) and keeps the keyboard here; with nothing to clear it closes the menu. + .event_filter(egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, tab: false, escape: true }) + .hint_text(tr("Search")) + .desired_width(ui.available_width()) + .font(crate::theme::regular(11.5)), + ); + widgets::select_all_on_focus(ui, &r, &q); + if opened { + r.request_focus(); + } + let ql = q.trim().to_lowercase(); + let groups: CompGroups<'a> = groups + .into_iter() + .map(|(h, cs)| (h, cs.into_iter().filter(|(_, n)| n.to_lowercase().contains(&ql)).collect::>())) + .filter(|(_, cs)| !cs.is_empty()) + .collect(); + let n = groups.iter().map(|(_, cs)| cs.len()).sum::(); + // A new search (or one cleared) starts again from the top. + if q != before || n == 0 { + hi = None; + } + let moved = step != 0 && n > 0; + if moved { + let at = hi.map_or(if step > 0 { -1 } else { n as i64 }, |h| h as i64) + step; + hi = usize::try_from(at.clamp(0, n as i64 - 1)).ok(); + } + let entered = enter.then(|| groups.iter().flat_map(|(_, cs)| cs).nth(hi.unwrap_or(0)).map(|(c, _)| *c)).flatten(); + if entered.is_some() { + ui.close(); + } + ui.data_mut(|d| d.insert_temp(state, (q, hi))); + CompSearch { groups, hi, moved, entered } +} + +/// A component menu's rows for what its search `found` (the one Up and Down are on lit); the +/// component clicked. +fn comp_rows(ui: &mut Ui, found: &CompSearch, checked: &impl Fn(NodeId) -> bool) -> Option { + let t = Tokens::get(ui.ctx()); + if found.groups.is_empty() { + widgets::label(ui, tr("No matching components"), t.text_tertiary); + return None; + } let mut picked = None; - menu_popup(&resp).width(200.0).show(|ui| { - let t = Tokens::get(ui.ctx()); - let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); - let mut pick = |ui: &mut Ui, c: NodeId, name: &str| { - let current = cur == Some(c); - if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { - if !current { - picked = Some(c); - } + let mut k = 0; + for (heading, comps) in &found.groups { + if let Some(h) = heading { + ui.label(egui::RichText::new(*h).font(semibold(11.0)).color(t.text_secondary)); + } + for (c, name) in comps { + let lit = found.hi == Some(k); + k += 1; + let label = if checked(*c) { format!("✓ {name}") } else { (*name).to_string() }; + let resp = crate::right::menu_row_lit(ui, &label, lit); + if lit && found.moved { + resp.scroll_to_me(None); + } + if resp.clicked() { + picked = Some(*c); ui.close(); } - }; - let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect(); - if !preferred.is_empty() { - heading(ui, tr("Preferred")); - for (c, name) in &preferred { - pick(ui, *c, name); - } - heading(ui, tr("All components")); } - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (c, name) in comps { - pick(ui, *c, name); - } - }); - }); - (resp, picked) + } + picked } fn prop_label(ui: &mut Ui, name: &str, icon: Icon) { diff --git a/crates/ui-egui/src/panels/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs index 34516477..b21e074c 100644 --- a/crates/ui-egui/src/panels/layout_panel.rs +++ b/crates/ui-egui/src/panels/layout_panel.rs @@ -23,9 +23,13 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> { doc.node(id).and_then(|n| n.parent).and_then(|p| doc.node(p)) } +/// Show selection sizing and layout controls; offer Add only when every selected container has +/// no auto layout and the selection is editable outside an instance. pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); + let layout_containers = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Group))); + let no_al = !sel.is_empty() && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.layout.is_none())); let al = common(doc, sel, |n| n.layout.clone()); let has_al = matches!(al, Some(Some(_))); let title = if has_al { tr("Auto layout") } else { tr("Layout") }; @@ -35,7 +39,7 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i)); let btns: Vec<(Icon, &str)> = if in_instance { vec![] - } else if frames && !has_al { + } else if layout_containers && no_al { vec![(Icon::Plus, crate::shortcuts::tip(tr("Add auto layout"), "layout.add"))] } else if has_al { vec![(Icon::Minus, crate::shortcuts::tip(tr("Remove auto layout"), "layout.remove"))] diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 7c9329ea..f4fd453e 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -34,7 +34,7 @@ pub(crate) use menu::menu_row_width; #[cfg(test)] pub(crate) use menu::preview_blend; pub use menu::{blend_menu, context_popup, menu_check, menu_item, menu_item_beside_checks, menu_item_with, menu_popup, menu_row}; -pub(crate) use menu::{block, menu_style, row, row_rect}; +pub(crate) use menu::{block, menu_row_lit, menu_style, row, row_rect}; use paints::{appearance, paints, variable_modes}; pub(crate) use stroke::{CAP_ART_W, cap_art, join_label}; @@ -108,6 +108,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { // The panel's edge, drawn here: the frame has no stroke (a stroke counts as margin and would // take 1 pt off each side of the panel (240 pt by default)). let edge = ui.max_rect(); + app.ui.right_panel_rect = Some(edge); ui.painter().line_segment([edge.left_top(), edge.left_bottom()], Stroke::new(1.0, t.border)); let w = ui.available_width(); // Top bar: people (you and the others in a live session), present, share. @@ -309,24 +310,16 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { _ => figcraft_color::Color::DEFAULT_PAGE, }; widgets::section_header(ui, tr("Page"), &[], false); - let mut open_picker = false; - let mut swatch_top = None; let mut new_hex = None; row(ui, 32.0, |ui| { let p = Paint::solid(bg); let sw = widgets::swatch(ui, &p, 16.0); - if sw.clicked() { - open_picker = true; - swatch_top = Some(sw.rect.min.y); - } + app.picker_control(crate::colorpicker::PickerTarget::PageBackground, &sw); if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") { new_hex = Some(h); } widgets::label(ui, "100 %", t.text); }); - if open_picker { - app.toggle_picker(crate::colorpicker::PickerTarget::PageBackground, swatch_top); - } if let Some(h) = new_hex && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) { diff --git a/crates/ui-egui/src/panels/right/effects.rs b/crates/ui-egui/src/panels/right/effects.rs index 2d458de9..528d19bf 100644 --- a/crates/ui-egui/src/panels/right/effects.rs +++ b/crates/ui-egui/src/panels/right/effects.rs @@ -334,9 +334,7 @@ fn tweaked(e: &Effect, f: impl FnOnce(&mut EffectKind)) -> Effect { fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, secondary: bool, edit: &ColorEdit<'_>) { let Some(c) = e.color(secondary) else { return }; let sw = widgets::swatch(ui, &Paint::solid(c), 16.0); - if sw.clicked() { - app.toggle_picker(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }, Some(sw.rect.min.y)); - } + app.picker_control(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }, &sw); if let Some(h) = widgets::text_field(ui, Id::unique(("fx_c", i, secondary)), &c.to_hex(), 80.0, "") && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h) { diff --git a/crates/ui-egui/src/panels/right/menu.rs b/crates/ui-egui/src/panels/right/menu.rs index 704b1fe4..790c8a63 100644 --- a/crates/ui-egui/src/panels/right/menu.rs +++ b/crates/ui-egui/src/panels/right/menu.rs @@ -81,6 +81,16 @@ pub(crate) fn menu_row_width(label_w: f32, trail: f32) -> f32 { /// A menu row (see [`menu_item_with`]); its response tells hovering too. It spans the menu and /// is at least as wide as its label, shortcut and art. pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response { + lit_row(ui, label, shortcut, art_w, art, false) +} + +/// A plain menu row highlighted when `lit`, as if hovered: the row Up and Down are on in a menu +/// whose search field keeps the keyboard. +pub(crate) fn menu_row_lit(ui: &mut Ui, label: &str, lit: bool) -> egui::Response { + lit_row(ui, label, "", 0.0, |_, _, _| {}, lit) +} + +fn lit_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32), lit: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); let text = ui.painter().layout_no_wrap(label.to_owned(), regular(11.5), Color32::PLACEHOLDER); let keys = @@ -91,7 +101,7 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl a11y::menu_item(&resp, label, shortcut, None); // The keyboard's item is highlighted like the hovered one. a11y::own_focus(&resp); - let hovered = resp.hovered() || resp.has_focus(); + let hovered = lit || resp.hovered() || resp.has_focus(); if hovered { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index a24fca55..11b368d1 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -113,10 +113,10 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N } }); } - let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round()); + let sm = common(doc, sel, |n| n.corners.smoothing * 100.0); widgets::sublabels(ui, tr("Corner smoothing"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::unique("smoothing"), Lead::None, sm, "%", col, 1.0); + let e = widgets::number_field(ui, Id::unique("smoothing"), Lead::Icon(Icon::CornerSmoothing, tr("Corner smoothing")), sm, "%", col, 1.0); app.field(&ids, "cornerSmoothing", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); if widgets::button(ui, tr("iOS"), false, true).on_hover_text(tr("Set corner smoothing to 60%")).clicked() { app.set_props(&ids, json!({"cornerSmoothing": 0.6})); @@ -254,9 +254,7 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: Some(p) => { widgets::paint_swatch(ui.painter(), sw, p); let range_swatch = ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click()); - if range_swatch.clicked() { - app.toggle_picker(crate::colorpicker::PickerTarget::TextRange { id, start, end }, Some(range_swatch.rect.min.y)); - } + app.picker_control(crate::colorpicker::PickerTarget::TextRange { id, start, end }, &range_swatch); if let Some(c) = p.solid_color() { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); if let Some(h) = @@ -271,9 +269,8 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: } None => { ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, tr("Mixed"), regular(11.0), t.text_secondary); - if ui.interact(r, Id::unique("range_mixed"), Sense::click()).clicked() { - app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end }); - } + let response = ui.interact(r, Id::unique("range_mixed"), Sense::click()); + app.picker_control(crate::colorpicker::PickerTarget::TextRange { id, start, end }, &response); } } // Back to the layer's fill. @@ -314,7 +311,7 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some() && let Some(l) = &list { - app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: l.len(), ids: ids.clone() }); + app.open_picker(crate::colorpicker::PickerTarget::Paint { fills, index: l.len(), ids: ids.clone() }, Some(header.rect)); } } else { style_picker::open(app, ui, &ids, slot); @@ -454,18 +451,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0)); widgets::paint_swatch(ui.painter(), sw, p); let sresp = ui.interact(sw.expand(2.0), Id::unique(("swatch", fills, i)), Sense::click()); - // While this paint is the one the picker edits, its swatch is held out of the popover's - // closing, so a press on it again reaches the toggle below. - let editing = matches!( - &app.ui.picker, - Some(crate::colorpicker::PickerTarget::Paint { fills: f, index: k, ids: held }) if *f == fills && *k == i && *held == ids - ); - if editing { - app.ui.picker_button = Some(sresp.rect); - } - if sresp.clicked() { - app.toggle_picker(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, Some(sresp.rect.min.y)); - } + app.picker_control(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, &sresp); let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); // The variable bound to this paint's colour (its key on the layers: `fill` or `fills//color`). diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs index 3fdada05..9d7a058c 100644 --- a/crates/ui-egui/src/panels/selection_colors.rs +++ b/crates/ui-egui/src/panels/selection_colors.rs @@ -47,9 +47,14 @@ fn colors(ui: &Ui, document: u64, doc: &Arc, ids: &[NodeId], interacti colors } -pub(crate) fn toggle(app: &mut FigApp, targets: Vec, top: Option) { +pub(crate) fn toggle(app: &mut FigApp, targets: Vec, response: Option<&egui::Response>) { let Ok(ds) = app.session.ds() else { return }; - app.toggle_picker(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }, top); + let target = PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }; + if let Some(response) = response { + app.picker_control(target, response); + } else { + app.toggle_picker(target, None); + } } /// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity @@ -71,9 +76,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId ui.push_id((&group.targets, "selection_color"), |ui| { crate::right::row(ui, 32.0, |ui| { let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour")); - if swatch.clicked() { - toggle(app, group.targets.clone(), Some(swatch.rect.min.y)); - } + toggle(app, group.targets.clone(), Some(&swatch)); let width = (ui.available_width() - 116.0).max(40.0); let linked_name = group .style @@ -81,9 +84,8 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId .and_then(|id| doc.style(id).map(|s| s.name.as_str())) .or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get(id).map(|v| v.name.as_str()))); if let Some(name) = linked_name { - if widgets::dropdown(ui, name, width, None).clicked() { - toggle(app, group.targets.clone(), None); - } + let response = widgets::dropdown(ui, name, width, None); + toggle(app, group.targets.clone(), Some(&response)); } else if let Some(color) = group.paint.solid_color() { if let Some(hex) = widgets::text_field_named(ui, ui.scope_id().with("hex"), &color.to_hex(), width, tr("Color")) { if let Some(color) = Color::from_hex(&hex) { @@ -99,9 +101,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId } } else { let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None); - if response.clicked() { - toggle(app, group.targets.clone(), Some(response.rect.min.y)); - } + toggle(app, group.targets.clone(), Some(&response)); } let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0)); if let Some(value) = widgets::text_field_named(ui, ui.scope_id().with("opacity"), &alpha, 44.0, tr("Opacity")) @@ -312,6 +312,23 @@ mod tests { assert_eq!(colors.len(), 2); } + #[test] + fn a_linked_name_control_tracks_itself_and_toggles_closed() { + let (mut p, a, _) = Panel::new(); + p.app.run("style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"})); + p.app.run("style.apply", json!({"id":a,"style":"Brand","target":"fill"})); + let name = p.frame(vec![]).into_iter().find(|(s, _)| s == "Brand").unwrap().1; + p.click(name); + assert!(p.app.ui.picker.is_some()); + let opening = p.app.ui.picker_button.unwrap(); + let opening_id = p.app.ui.picker_button_id.unwrap(); + p.frame(vec![]); + assert_eq!(p.app.ui.picker_button_id, Some(opening_id), "the swatch must not replace the name control"); + assert_eq!(p.app.ui.picker_button, Some(opening)); + p.click(name); + assert!(p.app.ui.picker.is_none()); + } + #[test] fn styles_menu_applies_a_color_variable_to_the_row() { let (mut p, a, b) = Panel::new(); diff --git a/crates/ui-egui/src/panels/typography/fields.rs b/crates/ui-egui/src/panels/typography/fields.rs index a7fe0199..cd3c485c 100644 --- a/crates/ui-egui/src/panels/typography/fields.rs +++ b/crates/ui-egui/src/panels/typography/fields.rs @@ -23,21 +23,7 @@ pub(super) fn font_rows( // Font family. let family = uniform(styles, |s| s.font_family.clone()); let fstyle = uniform(styles, |s| s.font_style.clone()); - row(ui, 32.0, |ui| { - let w = ui.available_width(); - let resp = font_dropdown(app, ui, ids, "fontFamily", family.as_deref(), w, editing); - let before_search = |ui: &mut Ui| !editing && apply_variable_item(ui, ids, "fontFamily", &resp); - if let Some(pick) = font_picker(ui, &resp, family.as_deref(), w.max(240.0), before_search) { - if let Some(f) = pick.picked { - remember_family(ui.ctx(), &f); - apply_style(app, "fontFamily", json!(f)); - } else if let Some(h) = pick.hovered.or(pick.highlighted) { - // The canvas shows the hovered family (or the one the arrow keys are on); - // closing without picking ends the preview. - app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": h}))); - } - } - }); + row(ui, 32.0, |ui| family_row(app, ui, ids, family.as_deref(), editing, apply_style)); // Style + size. // One of the two field columns of the section's grid. @@ -233,6 +219,47 @@ fn apply_variable_item(ui: &mut Ui, ids: &[NodeId], field: &str, anchor: &egui:: picked } +/// The font family field and its searchable menu (hovering a family previews it on the canvas). +fn family_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], family: Option<&str>, editing: bool, apply_style: &dyn Fn(&mut FigApp, &str, Value)) { + let range = figcraft_engine::textedit::selected_range(&app.session); + let w = ui.available_width(); + let resp = font_dropdown(app, ui, ids, "fontFamily", family, w, editing); + let offer = app.services.can_ask_for_local_fonts(); + let mut ask = false; + let before_search = |ui: &mut Ui| { + if !editing && apply_variable_item(ui, ids, "fontFamily", &resp) { + return true; + } + ask = offer && local_fonts_item(ui); + ask + }; + let pick = font_picker(ui, &resp, family, w.max(240.0), before_search); + if ask { + app.services.ask_for_local_fonts(); + } + let Some(pick) = pick else { return }; + if let Some(f) = pick.picked { + remember_family(ui.ctx(), &f); + apply_style(app, "fontFamily", json!(f)); + } else if let Some(h) = pick.hovered.or(pick.highlighted) { + // The canvas shows the hovered family (or the one the arrow keys are on); closing without + // picking ends the preview. + app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": h}))); + } +} + +/// "Use fonts installed on this computer…" above the font search (where the browser can list +/// them): true when clicked (the menu closes). +fn local_fonts_item(ui: &mut Ui) -> bool { + let picked = menu_item(ui, tr("Use fonts installed on this computer…"), ""); + if picked { + ui.close(); + } else { + ui.separator(); + } + picked +} + /// The variable font the shown styles use (one family, one variable face), if any. fn variable_face(styles: &[TextStyle]) -> Option> { let family = uniform(styles, |s| s.font_family.clone())?; diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rs index dbcf3bdb..14d1e133 100644 --- a/crates/ui-egui/src/popover.rs +++ b/crates/ui-egui/src/popover.rs @@ -140,8 +140,7 @@ impl Popover { self } - /// Whether a press outside it closes it (the default). The colour picker stays open, so a - /// press on a row beside it keeps editing the paint it is on. + /// Whether a press outside it closes it (the default). Disable while sampling with an eyedropper. pub fn close_on_outside(mut self, yes: bool) -> Self { self.outside = yes; self @@ -231,6 +230,9 @@ impl Popover { .into(), ]; ui.painter().set(bg, egui::Shape::Vec(shapes)); + if self.rect != panel { + ctx.request_repaint(); + } self.rect = panel; self.header = header; self.slot = slot; diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index d50158d1..9578b137 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -73,6 +73,14 @@ pub trait Services { fn gamepad_held(&mut self) -> u32 { 0 } + /// The fonts installed on this computer can be asked for and haven't been (the browser's Local + /// Font Access; the desktop app sees them anyway). + fn can_ask_for_local_fonts(&self) -> bool { + false + } + /// Ask for them (the browser asks the person): they join the font list by name, each read when + /// text uses it. + fn ask_for_local_fonts(&mut self) {} } pub type DialogFuture = Pin>>>; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index acad6f8f..200dd72b 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -79,6 +79,10 @@ pub fn under_white(c: Color32) -> Color32 { if c == BRAND { ACCENT } else { c } } +/// An AI agent attached over MCP: its avatar and the outline of the layers it works on (none of +/// the people colours, so it never looks like someone in a live session). +pub const AGENT: Color32 = Color32::from_rgb(0xF5, 0x9E, 0x0B); + pub const COMPONENT: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF); /// Handoff status badges (white text on these, in both themes). pub const DEV_READY: Color32 = Color32::from_rgb(0x08, 0x7D, 0x4F); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ba095c60..83b33f0e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -704,7 +704,7 @@ pub fn select_all_on_focus(ui: &Ui, r: &Response, text: &str) { /// with the editor already focused, which then gains no focus). Call it before adding the editor: /// the text is selected at once, so a key typed in the frame the edit starts (the frame after a /// double-click or F2 is often the one with the first key) replaces it. -fn select_all_next(ui: &Ui, te_id: Id, text: &str) { +pub(crate) fn select_all_next(ui: &Ui, te_id: Id, text: &str) { ui.data_mut(|d| d.insert_temp(select_all_id(te_id), true)); select_all(ui, te_id, text); } diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index c3bd47c5..99af5f0f 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -864,7 +864,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { PickerTarget::PluginField { plugin, .. } => crate::plugins_ui::field_window(app, ctx, plugin).map(|r| pos2(r.min.x - 248.0, r.min.y)), _ => None, }; - let pos = beside.unwrap_or(pos2(screen.max.x - app.ui.right_width - 260.0, 140.0)); + let open = beside.map(Open::At).unwrap_or_else(|| { + let top = app.ui.picker_button.map_or(140.0, |r| r.min.y); + let edge = app.ui.right_panel_rect.map_or(screen.max.x - app.ui.right_width, |r| r.min.x); + Open::Anchor(pos2(edge - 8.0, top)) + }); // The colour picker's own header — title 16 in, the X 8 in from the right — is the shared // popover's chrome now; the picker keeps its body. Its area id is [`window_id`], the one the // style picker looks up to tell a press in here from one outside. @@ -873,13 +877,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { // colour it just sampled — is the press, not a press outside: neither may close the picker. let picking = app.session.tool.pick_only || app.session.tool.picked.is_some(); pop = pop.close_on_outside(!picking); - // Closing is the shared widget's: the X, Escape and a press outside it. Presses in the panel - // beside it keep it (swatches and fields there switch the colour it edits); the control that - // opened it is held too, so pressing it again reaches its own toggle. - let panel = Rect::from_min_max(pos2(screen.max.x - app.ui.right_width, screen.min.y), screen.max); - let mut held: Vec = vec![panel]; - held.extend(app.ui.picker_button); - let closed = pop.show(ctx, Open::At(pos), Hold { rects: &held, escape: false }, |ui| { + // Only the opening control is held out: any other sidebar or canvas press dismisses it. + let mut held: Vec = app.ui.picker_button.into_iter().collect(); + held.extend(crate::variable_ui::picker_rect(ctx)); + let closed = pop.show(ctx, open, Hold { rects: &held, escape: false }, |ui| { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...). if let Some(c) = app.session.tool.picked.take() { @@ -1043,6 +1044,7 @@ pub fn close(app: &mut FigApp) { app.ui.picker = None; app.ui.picker_popover = None; app.ui.picker_button = None; + app.ui.picker_button_id = None; } /// Swatches wrapped in rows inset 16 points from both sides of the picker. diff --git a/crates/ui-egui/src/widgets/font_preview.rs b/crates/ui-egui/src/widgets/font_preview.rs index ad05645d..f465041e 100644 --- a/crates/ui-egui/src/widgets/font_preview.rs +++ b/crates/ui-egui/src/widgets/font_preview.rs @@ -53,6 +53,10 @@ pub fn texture(ctx: &egui::Context, style: &TextStyle, text: &str, budget: &mut /// A family's name in its own regular style (None when the family doesn't draw itself: symbol /// fonts, or it resolves to another family). pub fn family(ctx: &egui::Context, family: &str, size: f64, budget: &mut usize) -> Option { + // A font on this computer that the browser named is read when text uses it, not to preview it. + if figcraft_text::local_only(family) { + return None; + } let styles = figcraft_text::family_styles(family); let style = styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first())?.clone(); let face = figcraft_text::find_face(family, &style)?; diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index b1001a0e..11708388 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -1149,7 +1149,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { if let Some(e) = &p.edit { commit_edit(app, &doc, e); } - app.ui.picker = Some(PickerTarget::Style { id, index }); + app.open_picker(PickerTarget::Style { id, index }, None); } Some(Act::Unbind(id, field)) => { app.run("style.bind", json!({"style": id, "field": field})); diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index ea2cc54f..5dfc3c8e 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -1,6 +1,6 @@ # Auto layout and constraints parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** minor (Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) Auto layout is the most used layout feature in Figma and the one where small differences (a hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%, @@ -9,6 +9,24 @@ positions it implies (`crates/layout/src/behaviour_tests.rs`, 64 cases, and `crates/engine/src/al_behaviour_tests.rs` through commands), and all pass; nothing yet against real Figma files. Remaining: 3–6 h. +## Adding auto layout to a group + +With one group selected, Add auto layout converts that group into a frame in place, retaining its +id, name, parent and direct children. The Layout sidebar’s +, context menu, Shift+A and API calls +all dispatch `layout.add`, so they share the conversion and a single undo step restores the group. +The layout settings are inferred from visible, non-absolute children, matching the layout engine’s +flow. Hidden and absolute children therefore do not distort direction, gap or padding. A converted +group keeps Fill sizing on either axis in its auto layout parent. Sorting by position preserves +mask boundaries and the paint order within masked runs. The sidebar offers Add only when every +selected container has no auto layout, including mixed selections. + +This follows the Figma behavior reported by the contributor and the sidebar workflow users know from +similar design tools: start with a group and make its children participate in auto layout. +Wrapping the group would instead make the entire group one layout item, leaving its children +outside the new frame’s layout flow and adding an unnecessary level to the layer tree. Converting +the selected container makes later changes to a child’s size reflow its siblings directly. +Selecting several groups still wraps that selection in a shared auto layout frame. + ## Rules from the documentation Status: **pass** (a case in the suite holds), **fixed** (the suite found a divergence, now fixed), @@ -69,7 +87,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` | | Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | | | Layout grids: columns, rows, grid; stretch/left/centre/right; variables bound | done | | -| Suggest auto layout / "Add auto layout" on a selection | done | adding auto layout to an empty frame keeps its size, Fixed (it collapsed to 20×20); a frame with content still hugs it, as does a new frame wrapped around a selection (#369). `node.set` with auto layout keys on a mix of layers applies them to the frames and warns about the rest instead of failing the whole call (#603) | +| Suggest auto layout / "Add auto layout" on a selection | done | the Layout sidebar exposes Add auto layout (+) for groups and frames using the same `layout.add` command as the context menu; a single group becomes a frame in place, keeping its id, name and direct children; adding auto layout to an empty frame keeps its size, Fixed (it collapsed to 20×20); a frame with content still hugs it, as does a new frame wrapped around a selection (#369). `node.set` with auto layout keys on a mix of layers applies them to the frames and warns about the rest instead of failing the whole call (#603) | | Real Figma files laid out identically | **not checked** | needs `.fig` or REST import of real files ([gaps](gaps.md) #1, #3) | | Performance: re-flow only where sizes or places changed | done | measured in `cargo xtask bench` (components at scale) | @@ -77,6 +95,8 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M2 review fixes: conversion preserves mask runs and Fill sizing per axis; layout inference uses visible, non-absolute flow children; the sidebar hides Add for mixed selections containing existing auto layout. Regression coverage includes rendered mask pixels, visible-child positions, parent layout stability and the sidebar accessibility tree. Numbers unchanged | +| 2026-10-29 | minor | M2: the Layout sidebar exposes Add auto layout (+) for groups. `layout.add` converts one selected group into a frame in place rather than wrapping it: same id, name and direct children; the layout arranges those children, and one undo restores the group. Sidebar, menu, shortcut and API calls all use the corrected command. This follows the contributor’s observed Figma behavior and the workflow familiar to users of similar design tools. Tests cover direct-child reflow, stable hierarchy, undo/redo and wrapping a multi-group selection. Numbers unchanged | | 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged | | 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | | 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 116295b4..ff94b0a0 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-29 · **Change:** minor (board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) **Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that @@ -34,7 +34,7 @@ all end with the same geometry. | Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks | | Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, more shapes (cylinder, cross, star…), shape libraries | missing | | -| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); re-routed when either end moves or resizes; a deleted end stays where it was | +| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was | | Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) | | Connector waypoints placed by hand | missing | routes are computed from the ends; no bend points of your own | | Quick-create handles (drag out a connected copy) | missing | | @@ -56,13 +56,15 @@ all end with the same geometry. | Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files | | Audio and huddles | missing | | | AI features (summaries, sorting stickies, generating boards) | missing | | -| Dot grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it; 24 units apart, doubling when zoomed out until the dots are at least 14 points apart, the dots the next doubling drops fading out first; dark dots on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per dot; never in exports | +| Grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it. Dots by default, or lines, graph paper (a strong line every 2–12 cells over fainter ones, like Japanese section paper) or none; spacing (4–512), mark size and contrast are settings kept with the preferences (`board.grid`; Preferences ▸ Board grid). Zoomed out it thins out (dots and lines by halves, graph paper down to its strong lines) until the marks are at least 14 points apart (10 for lines), the marks the next step drops fading out first; dots stay about a point and a half across on screen (1 to 3 points), scaled by the size setting. Dark marks on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per mark, a crossing of two lines blended once; never in exports. FigCraft's own settings: the incumbent's board grid has none | | `.jam` import and export | missing | see [`file-format-parity.md`](file-format-parity.md) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M12: board grid settings (`board.grid`): dots, lines, graph paper or none, with spacing, size and contrast, in Preferences; the default grid looks as before. Numbers unchanged (beyond the incumbent's behaviour) | +| 2026-10-29 | minor | M12, #1387 (connector tool): while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | | 2026-10-24 | minor | M12: the dot grid on board pages (a render pass under the layers), dragging a selected connector's end onto another object or empty canvas (`board.reattach`), curved connectors, and elbows that go around the objects they join. Breadth ~35% → ~37%, ready ~20% → ~21%, ~30–60 h → ~28–56 h | | 2026-10-24 | minor | Facilitation (M12): a shared timer and voting sessions. Breadth ~35% → ~40%, ready ~20% → ~24%, ~30–60 h → ~26–54 h | | 2026-10-24 | minor | Templates (retrospective, brainstorm, weekly planner, flow chart, kanban board) from a toolbar picker or `board.template`, and tables (`board.table`, `board.tableEdit`): breadth ~35% → ~40%, ready ~20% → ~24%, remaining 30–60 h → 26–54 h | diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index f350013c..b09635c1 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -8,7 +8,7 @@ embeds, invite links, document sync end to end (several apps edit one file), liv selections, avatars and follow. **Step 3 is implemented**: per-user undo and redo, rejection handling and offline queueing with reconnect (`crates/collab`, wired into the engine's live session and the transport). **Step 4 is implemented**: the browser build joins sessions a desktop -app hosts, and `figcraft://` invite links open the app and join. Styles, variables, collections, +app hosts and publishes files to a server, and `figcraft://` invite links open the app and join. Styles, variables, collections, modes and comments created in a session take ids from their creator's id space, and others' text carets, cursor chat and spotlights show live (see Presence). **Step 5 is implemented**: the standalone `figcraft-server` (`apps/figcraft-server`, running it: [`server.md`](server.md)) keeps @@ -198,7 +198,10 @@ an older client never gets a message it can't read. A server that takes them ans client, right after its welcome, with a `ready` signal about the client itself; a client sends signals only after that, so an older server never gets one either (the app says the host runs an older version). A signal is a kind and a text, so kinds added later are skipped by clients and -dropped by servers that don't know them. +dropped by servers that don't know them. The `name` kind (a rename) came after the first signals: +a server that knows it keeps the new name and tells everyone with the client's `PeerJoined` +(apps take a `PeerJoined` for someone already there as their new name and colour); an older +server drops it, so the session keeps showing the name the client joined with. **Open hosting and reports** came later within version 2 the same way. A connection that opens with `OpenHosting` asks a server about its hosting, publishes without the key when the server is @@ -247,7 +250,7 @@ Client → server: | `Admin {publish {name, snapshot} \| files \| links {file} \| createLink {file, access, label} \| revoke {file, link} \| remove {file}}` | A request on a managing connection; each gets one `Admin` reply, in order. | | `OpenLibrary {protocol, file, token, client_version}` | Instead of `Hello`: use a file's team library with one of the file's share links. | | `Library {fetch {have?, usage?} \| publish {archive} \| usage}` | A request on a library connection; each gets one `Library` reply, in order. `usage` is `{file, components: [(id, instances)]}`. | -| `Signal {kind, text}` | Ephemeral presence, only after the server's `ready`: `chat` (the cursor chat message; empty: closed) or `spotlight` (`"on"`, or empty: stopped). | +| `Signal {kind, text}` | Ephemeral presence, only after the server's `ready`: `chat` (the cursor chat message; empty: closed), `spotlight` (`"on"`, or empty: stopped) or `name` (your new name: the server keeps it, cleaned like a hello's, and sends everyone, you too, your `PeerJoined`; never passed on as a signal). | | `OpenPlugins {protocol, key, client_version}` | Instead of `Hello`: use the server's plugin directory; `key` empty to browse, the server key to publish too. | | `OpenHosting {protocol, client_version, file, owner}` | Instead of `Hello`: ask about the server's hosting; publish without the key (`Admin publish`) when it is open to the public; with a file's id and owner token, manage that file (`Admin links \| createLink \| revoke \| rename \| remove` of it). Answered with `Hosting` or `Refused`. | | `Admin {rename {file, name} \| listing \| reports \| block {ip} \| unblock {ip} \| blocked}` | Later managing requests: rename a file on the server, the files with what the operator moderates by, the reports, blocking addresses (an IPv6 address: its /64). Owner tokens may only rename their file. | @@ -438,8 +441,10 @@ resuming with its offline edits, and leaving the session going back to snapshot With another document in front (or before the welcome arrives), cursors show in page coordinates on whatever page you look at and nothing is outlined. - Colours and names: the host hands out the first free colour of an eight-colour palette and - "Guest N" to people without a name (Preferences: your name, `pref.userName`; also in the Share - popover). After a newcomer's `Welcome` the host sends it its own `PeerJoined`, which is how it + "Guest N" to people without a name (Preferences ▸ Your name, `pref.userName`; also in the Share + popover and the Join dialog). Changing it while in a session renames you there at once (a + `name` signal, sent again after a reconnect, whose hello has the old name); clearing it keeps + the session's name until you join again. After a newcomer's `Welcome` the host sends it its own `PeerJoined`, which is how it learns its name and colour. - Idle cost is zero: network threads block, wake the window (`request_repaint`) only when a message arrives, cursors only ask for frames while they glide, and text carets never do. @@ -527,7 +532,8 @@ resuming with its offline edits, and leaving the session going back to snapshot is final; a first join that can't reach the host fails at once. A reconnect resumes as the same client: the guest's connection keeps the session id, client id and last sequence number from the messages it read and sends them in its next `Hello` (see Offline and reconnect). -- The browser build joins (see *Built (the browser client)*); hosting needs the desktop app. +- The browser build joins and publishes to a server (see *Built (the browser client)*); hosting + needs the desktop app. *Built* (sharing with a join code; `figcraft_collab::code`, `collab.share`, `collab.join {code}`): @@ -585,7 +591,8 @@ resuming with its offline edits, and leaving the session going back to snapshot Whoever sees the code learns the host's address. Everyone who joins edits; a host without an IPv4 address gets no code and shares its link. - **In the browser** a code joins like a link (the page asks the server over its own WebSocket, - then opens the session's); sharing needs the desktop app. + then opens the session's); it shares online through the server in Preferences as the desktop + app does, and not on the local network (a page can't listen). *Built* (your online files; `figcraft_engine::online`, `collab.files`, `collab.openFile`, `collab.forgetFile`, `collab.manageFile`): @@ -600,7 +607,7 @@ resuming with its offline edits, and leaving the session going back to snapshot once when the list changes, so a crash doesn't lose a token. A file published with the server's key has no owner token and is the operator's to manage: it isn't recorded. - **The token is a password.** It is stored on this computer only (the desktop app's preferences - file; the browser build keeps no preferences and can't publish), is never logged, and no + file; the browser build keeps only your name across reloads, so a token it gets lasts as long as the page), is never logged, and no command result, summary or `ui.inspect` carries it or the owner's own links: `collab.files` says `hasOwnerToken: true`. The record's debug output leaves the secrets out. A session opened from a record holds no token either (`published.owner` is null there): requests that need it @@ -861,9 +868,10 @@ Decisions, as built: welcomed as new after all (the server restarted without its state) starts over from the snapshot and is told how many unsent changes couldn't be kept. - **Catch-up.** The server keeps a log of accepted batches (`Limits::max_log_batches`, default - 10 000, and `max_log_changes`, default 1 000 000). If every batch after the client's sequence - number is still in it, the welcome carries them (`CatchUp::Delta`); otherwise the whole document - (`CatchUp::Snapshot`). + 10 000, `max_log_changes`, default 1 000 000, and `max_log_bytes`, default 64 MiB, which a + desktop host uses as they are and `figcraft-server` takes from its `max_log_mb`). If every batch + after the client's sequence number is still in it, the welcome carries them (`CatchUp::Delta`); + otherwise the whole document (`CatchUp::Snapshot`). - **What happened to the batches in flight.** The server remembers per client the last batch id it processed and the latest refused ids (at most 1 024). Batches up to that id that the catch-up doesn't confirm were refused (the user is told); the others are sent again, in order, keeping @@ -1002,7 +1010,7 @@ chooses to share on the network; the default is the invite link with a random to resuming connection), followed by client-scoped ids for document-level objects and remote text carets on the canvas. 4. **Browser client** (WebSocket through `web-sys`). **Done** (the web build joins - desktop-hosted sessions; `figcraft://` links open the desktop app and join). + desktop-hosted sessions and publishes files to a server, #917; `figcraft://` links open the desktop app and join). 5. **Standalone `figcraft-server`** with persistence, compaction, share links and permissions. **Done** (`apps/figcraft-server` on the shared hub; edit and view links, the server key, publishing from the app; see [`server.md`](server.md)), followed by open hosting for a public diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index c8a9d436..32755cdc 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); 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); 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); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -12,11 +12,14 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Feature | Figma | FigCraft | Hours | |---|---|---|---| -| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Left: server archives beyond Linux | 25–55 | -| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | +| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Left: server archives beyond Linux | 25–55 | +| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). Left: server archives beyond Linux | 25–55 | +| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | +| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | +| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | -| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | -| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | +| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | +| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | | Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 | | Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 | @@ -26,6 +29,16 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 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 | Where an AI agent works (M8, #1416): an agent attached over MCP (`figcraft-cli mcp --attach`) changed the file with no sign of where or whether it was still at work. Now, while it runs commands, the people row at the top of the right panel shows its avatar, ringed in its own colour and named as its comments are signed ("Claude (agent)"), and the canvas outlines the layers its last edit changed (the innermost ones), or that it last selected, in that colour with its name. Both go 8 seconds after its last command, and only the file it works in shows them. Display only: no undo step, never saved, never sent to a live session's other people, and the user's own commands never make one (`agent_presence_tests` in the engine, `live::agent` tests in the UI). Ready unchanged | +| 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged | +| 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | +| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address and block (`Action::Admin`), on every server, with the server key and with an owner token alike; listing and looking aren't counted. Before, a managing connection with the key had no limit at all and an owner token only the message rate, so one address could ask for hundreds of 10,000-round code stretches and `links.json` writes a second (100 renames in a row: 100 served before, 60 now, then "too many changes to this server's files from your address…"; another address is unaffected). A typed or new join code is found through an index of code hashes to the files holding them, instead of opening every link's code on the server; results are the same, a copied file folder holding the same code included. The protocol is unchanged. Tests: `admin_budget_tests`. Numbers unchanged | +| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | +| 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | +| 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | +| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | +| 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | | 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 256 MiB (`figcraft_doc::ImageData::MAX_BYTES`, the cap the `.fig`, `.sketch` and REST importers read images with, so an image a document imported still reaches everyone) — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A key that already holds an image can't be given other bytes (producers key images by their bytes, and the REST importer's placeholder keys always hold the same picture). A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. History written before these checks — a server's snapshot or log, a file published to a server, a snapshot a client joins with — applies without the image entries they refuse (judged against the state they apply to, so a logged replacement under an existing key, or removal of an image in use, is left out too), instead of the file not opening or a restart dropping every edit after the image; a restarted server that left image entries out, or whose saved log puts images, catches every resuming client up with its snapshot instead of that log, which it can't check against what each client holds. An image a layer or style still shows can't be removed: removing it and putting other bytes under its key would otherwise swap the picture. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged | | 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged | | 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged | diff --git a/docs/components-parity.md b/docs/components-parity.md index df48b9c4..6dc4bfae 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -18,11 +18,12 @@ accounts). | What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints | | Component sets / variants, add variant, switch | done | Combine as variants is in the Object menu, and the canvas and layers context menu and the Design panel offer it when two or more main components are selected; Add variant on a main component not in a set yet makes it a set of itself and the new variant, one undo step (#903); components that are variants already aren't combined again (it used to put a set inside a set) | | A main's copy within its file is an instance of it: Duplicate, Alt-drag, Copy then Paste | done | a variant duplicated in its set stays a new variant and a whole set copies as a new set; only the copied roots become instances (a main inside a copied frame is copied as a main); Cut then Paste and pastes into other files keep the component (#892) | +| A component can't contain its own instance, directly or through other components | done | refused with the loop named (creating, placing, swapping, switching variants, moving, pasting, an instance-swap value); each variant counts as its own component, so a variant may hold a sibling variant's instance unless that one leads back to it; a dropped instance lands on the page; a file holding a loop opens with one instance detached and a note, any other file unchanged; a loop merged in a live session stops expanding where an instance would show itself again (#1084) | | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | | Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` | -| Properties: boolean, text, instance swap (preferred values), variant | done | every component menu scrolls past 320 pt, the default of an instance-swap property's too (#1338); no search in them yet | -| Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature | +| Properties: boolean, text, instance swap (preferred values), variant | done | listed in the order they were made, a rename keeping the place (#1536; the shared ones don't reorder by drag yet); every component menu scrolls past 320 pt, the default of an instance-swap property's too (#1338); the instance-swap menus (an instance's property and swap menus, a property's default, the playground) open with a search field that filters by name as you type, with Up, Down and Enter to pick and Escape to clear then close; the slot and preferred components menus have no search yet | +| Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature; Make slot asks for the name first (a field under the frame's Slot row), and slots are renamed in the component's Properties list (#1537); no rename with only the slot's frame selected yet | | Exposed nested instances | done | hidden ones hide their properties | | Descriptions | done | | | Instance swap: panel, Alt-drop from Assets | done | the panel keeps overrides on same-named layers, an Alt-drop from Assets only text layers' | @@ -77,6 +78,8 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 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-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | | 2026-10-26 | minor | An instance-swap property's default menu scrolls (#1338): on the main component's Properties row the menu listed every component in the file at full height, so in a file with many (an icon library) the bottom ones ran off the screen and couldn't be picked. It now scrolls past 320 pt like the instance's swap menu, is at least 200 pt wide, ticks the current default and is named for assistive technology (`component_swap_default_tests`). Left: searching these menus. Numbers unchanged. | | 2026-10-24 | minor | Variables on a text style's fields from the style editor: the font and font style menus start with Apply variable (string variables), and the size, line height and letter spacing fields show a variable button on hover (number variables). A bound field shows its variable in place of the value; a click on it picks another variable and its detach button unbinds it (`style_picker_tests`). Left: the other effect and grid fields (commands only), library variables in the picker. Numbers unchanged. | | 2026-10-23 | minor | Instance-swap properties in the component playground: the Inspect tab's Playground shows a dropdown for each, with the same menu as the Design panel's (the property's preferred components first, then every component in the file). The component picked changes the preview and the code only, never the file. Left: slots in the playground. Numbers unchanged. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4b1f586a..6712ee24 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -65,6 +65,13 @@ window, with its tabs, panels and canvas: `engine.execute` acts on the file it s last in front (the one the user clicked last, or the last `window.new` / `window.focus`). An unknown id is an error. +**Documents.** `engine.execute`, `document.inspect`, `node.get` and `ui.render` take an optional +`document`: an open file's `id` (from `doc.list`, or `document.inspect`'s `id`) or its path. The +request acts on that file without bringing its tab to the front; the tab in front comes back +afterwards unless the command opened or switched to another file itself. A file that isn't open is +an error, and so is a `document` naming a file behind the tab in front on any other method +(`ui.screenshot`, `ui.input`, `ui.key`… act on what the window shows). + ## Confining file access ```sh @@ -102,6 +109,11 @@ JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"] (as in `docs/mcp.md`). A parameter the command's doc (`engine.commands`) doesn't list is an error naming it and the ones it takes, never ignored; `ids` and `id` are always taken. +`agent` (a string, next to `command`) runs the command for an AI agent of that name, as the MCP +server does with its client's name: comments it makes are signed " (agent)" ("AI agent" for +an empty name), marked `"agent": true`, and keep their own read state. Without it the command acts +as the user (the name from Preferences). + ```json {"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}} {"id":1,"ok":true,"result":{"id":2}} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6a5a9d2e..be665e55 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **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); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); 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); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,7 +17,8 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | | `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA | | `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%); the rules for master backgrounds, symbol clipping, mask opacity, bitmap fills, image overrides, stretching stacks, blur radii, colour adjustments, gradient interpolation and text growth checked against Sketch's renders of files built for each case (black-box, synthetic) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session). Known limitations: a plain group (not a frame) that fills a stack keeps its layers' size where Sketch stretches them; a font Sketch can't find draws in the installed family's own weight (Sketch falls back to a regular face); text in the system SF Pro comes out a few percent narrower than Sketch's; macOS's reserved system fonts (PingFang) aren't found; brightness past about +0.4 is approximated (image filters can't add); OKLab gradients become sRGB stops (FigCraft gradients mix in sRGB) | | `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | direct `.jam` opening is not supported (#1342); its `canvas.fig` can be read best effort after renaming a copy to `.fig`, with generated sticky/shape backgrounds and text kept (#1341), as ordinary design layers rather than native board objects (see [boards-parity](boards-parity.md)); writing `.jam` is not supported; `.deck` out of scope | — | @@ -29,7 +30,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft | Notes | Hours | |---|---|---|---|---| -| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on one path as text on a path (on several paths as outlines); fuzzed | 2–4 (filters) | +| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, `color()` (sRGB, Display P3) and `color-mix()` (sRGB, OKLab, OKLCH) colours, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on one path as text on a path (on several paths as outlines); a pattern's tile is drawn once per scale however many layers use it, and its layers count in the import's 100,000-layer budget; fuzzed | 2–4 (filters) | | PNG, JPG | yes | yes | embedded ICC profiles (PNG `iCCP`, JPG APP2) and PNG `sRGB` / `gAMA` / `cHRM` chunks convert the image into the file's colour space; no profile (or CMYK, damaged): taken as sRGB | — | | GIF | yes (animated) | **yes**, animated | the file is kept as is; the canvas and PNG, JPG and PDF exports show the first frame (SVG exports embed the file, so viewers that animate GIFs animate it), the presenter and the preview play it (frames decoded on first play: at most 500 frames and 128 MB, past that the rest don't play; always loops) | — | | WebP | yes | yes | embedded ICC profile honoured | — | @@ -183,6 +184,9 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 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 `