diff --git a/Cargo.lock b/Cargo.lock index 9dc0754bc..596ddb407 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1820,6 +1820,7 @@ dependencies = [ "figcraft-color", "figcraft-doc", "figcraft-geom", + "flate2", "imbl", "postcard", "serde", diff --git a/ROADMAP.md b/ROADMAP.md index 9635a7407..199b17fb9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); 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 @@ -358,6 +358,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M4, gap 4: the eyedropper with Cmd/Ctrl+Shift held makes a colour style or variable of the colour it picks, as the reference app's eyedropper does, instead of filling the selection. It asks the UI for a Create from color window (`dialog.createFromColor`; the control channel never opens it). The window shows the colour and offers Style or Variable, a name (the next free "Color n") and, for a variable, a collection: the file's first, or a new one made with it in the same undo step. Create runs `style.create` or `variable.create`. Tests: `eyedropper_create_tests`, `sampled_color_tests`. | +| 2026-10-31 | patch | M9, #1665: the document in a live session's welcome goes compressed (deflate) to clients whose hello announces `packed`, about seven times smaller on the wire (a synthetic file of 4,040 layers: 397 KB to 53 KB); older clients keep getting it uncompressed, and the client reads a packed document only up to the size an uncompressed welcome could have had. | +| 2026-10-31 | minor | M8, #1673: an attached client whose request ran past the control channel's 60 s timeout was told "timed out waiting for the UI: screenshots and input need the FigCraft window on screen" while the request went on and was applied. Now the UI marks a request started (a new `Running` state of its request gate) just before it runs it and never runs one the transport already gave up on. At the timeout the reply says which happened: not started, so dropped and not applied; or still running, so it will take effect and the document should be checked before retrying. A reply that arrives just as the timeout fires is still delivered. Only `ui.screenshot` and `ui.input` keep the window message. Tests: `control_server_tests` (a started request times out as still running, a cancelled one never starts) and `idle_tests::a_request_the_transport_gave_up_on_is_never_applied` (from a frame and from a hidden window). Numbers unchanged | +| 2026-10-30 | minor | M12, gap 24: a board keeps each ended vote's results in the file (the last 20), as the reference app keeps each voting session's results until they're deleted. A record holds the vote, its ballots, the names the voters went by and when it ended, so someone who has left a live session still shows by name. The Results popover lists them newest first, with arrows to earlier votes and Delete results (`board.vote {action: delete, index}`). Clear results still only takes the results on the board off it. `board.voteResults` lists `sessions`. The results are saved (`vote_history`; older files open without it, and a vote ended in one still shows), synced in live sessions (`DocProp::VoteHistory`), and like the vote they're not undo steps: undo and redo carry them over. Tests: `vote_history_tests`, `facilitate_tests` and `vote_history_ui_tests`. | +| 2026-10-30 | minor | M12, gap 24: an ended vote's results list who voted for each object and how many times ("Ada ×2, Bo"), as the reference app shows how each participant voted once a vote ends; while it runs, people still see only their own votes. `board.voteResults` gives them per result as `voters: [{voter, name, votes}]`. In a live session a voter is named as the session knows them, and someone who has left shows as such. The Voting row no longer asks for facilitator-only ending: the reference app's help says anyone in the file can end a vote, as here. Tests: `facilitate_tests` (offline and live, including a person who left) and `vote_voters_tests` (the Results popover). | | 2026-10-30 | minor | M12, gap 24: while the stamp tool is over an object, or a stamp is dragged over one, that object is outlined (the canvas's hover outline): the one the stamp will stick to, found by the same rule as `board.stamp` and `settle_stamps`. Found while checking it: a drag dropped what it moved into the auto layout of a sticky or shape with text under the pointer, so a stamp dragged onto a sticky became part of its layout; board objects (stickies, shapes with text, tables, stamps, labels) now take no drops, which go to what is under them, and a stamp already inside its sticky from such a drop moves with it once. Checked in the app. Numbers unchanged. Tests: `board_connect_tests::the_object_a_stamp_will_stick_to_is_highlighted`, `board_tests::stamps_stick_to_what_they_land_on`. | | 2026-10-30 | minor | M12, gap 24: a stamp sticks to the object it lands on (the topmost layer under its centre other than stamps, connectors, their labels and ink) and moves with it, as the incumbent's stamps do: `BoardItem::Stamp` keeps it in `on` (left out when empty, so older files open with their stamps on nothing), and the board's normalize pass carries a stamp along when its object moves or turns, once when both move together. A stamp dragged onto another object sticks to that one and off everything to nothing; one whose object is deleted stays where it is, on nothing. Undo and live sessions go with the edit that moved the object. Checked in the app (two stickies moved with their stamps). No highlight while a stamp hovers yet. Numbers unchanged. Test: `board_tests::stamps_stick_to_what_they_land_on`. | | 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`#join=…`). A browser build served beside its server names it with ``, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged | diff --git a/apps/figcraft-server/src/test_util.rs b/apps/figcraft-server/src/test_util.rs index da5b54a85..f43199ffe 100644 --- a/apps/figcraft-server/src/test_util.rs +++ b/apps/figcraft-server/src/test_util.rs @@ -165,7 +165,10 @@ impl Raw { pub fn next(&mut self) -> ServerMsg { loop { if let Message::Binary(b) = self.0.read().unwrap() { - return message::decode(&b, usize::MAX).unwrap(); + // As the app's clients do: a packed welcome is read before anything sees it. + let mut m = message::decode(&b, usize::MAX).unwrap(); + message::unpack(&mut m, usize::MAX).unwrap(); + return m; } } } diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index a7c08f8a9..28be1c122 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -28,6 +28,9 @@ enum WaitFailure { Timeout, /// The UI side dropped its sender before replying (the app is closing). Disconnected, + /// The UI started the request before the timeout and is still running it: it takes effect + /// when it finishes, though nobody waits for its reply (#1673). + StillRunning, } /// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses. @@ -46,10 +49,30 @@ fn wait_for_reply(rx: &std::sync::mpsc::Receiver, gate: &RequestGa fn after_timeout(rx: &std::sync::mpsc::Receiver, gate: &RequestGate) -> Result { match gate.cancel_if_open() { RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected), + // It may have finished just now: its reply is the truth then. + RequestState::Running => rx.try_recv().map_err(|_| WaitFailure::StillRunning), RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout), } } +/// What a request that got no reply is told. Only a request that needs a drawn frame can be held +/// up by the window being out of sight; any other was either never started (the app was busy, so +/// it was dropped and never applied) or is still running and will be applied (#1673). +fn timeout_message(failure: &WaitFailure, needs_frame: bool) -> &'static str { + match failure { + WaitFailure::Disconnected => "the FigCraft window closed before answering", + WaitFailure::StillRunning => { + "the app is still running this request after 60 s: it will take effect when it finishes, but its reply is lost. Check the document before retrying" + } + WaitFailure::Timeout if needs_frame => { + "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)" + } + WaitFailure::Timeout => { + "timed out after 60 s before the app started this request (it was busy, or its window isn't drawing): it was not applied" + } + } +} + /// Deferred requests for a hidden window must not make the UI's queue grow without a bound. pub fn drain_queue(queue: &Queue, target: &mut Vec) { target.retain(|request| !request.is_cancelled()); @@ -171,6 +194,7 @@ fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?; continue; } + let needs_frame = matches!(method.as_str(), "ui.screenshot" | "ui.input"); pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() }); drop(pending); if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() { @@ -181,12 +205,7 @@ fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc Ok(res) => json!({"id": r.id, "ok": true, "result": res}), Err(e) => json!({"id": r.id, "ok": false, "error": e}), }, - Err(WaitFailure::Timeout) => { - json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"}) - } - Err(WaitFailure::Disconnected) => { - json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"}) - } + Err(failure) => json!({"id": id, "ok": false, "error": timeout_message(&failure, needs_frame)}), } } Err(e) => { diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 2d1b405d1..aeb5266ef 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -162,6 +162,38 @@ fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() { assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision"); } +/// #1673: a request the UI already started can't be cancelled any more. Its timeout says it is +/// still running and will take effect, not that the window is hidden; one that finished just as +/// the timeout fired gets its real reply. +#[test] +fn a_started_request_times_out_as_still_running() { + let gate = RequestGate::new(); + assert!(gate.start(), "an open request starts"); + let (tx, rx) = mpsc::channel::(); + let Err(failure) = wait_for_reply(&rx, &gate, Duration::ZERO) else { panic!("no reply was sent") }; + assert_eq!(failure, WaitFailure::StillRunning); + assert_eq!(gate.state(), RequestState::Running, "the timeout doesn't cancel a running request"); + let message = timeout_message(&failure, false); + assert!(message.contains("will take effect") && !message.contains("window"), "{message}"); + tx.send(ControlReply { id: json!(3), result: Ok(json!({"done": true})) }).unwrap(); + assert_eq!(wait_for_reply(&rx, &gate, Duration::ZERO).unwrap().id, json!(3), "a reply that made it is the truth"); +} + +/// #1673: a request the transport gave up on before the UI started it never starts, and its +/// timeout says it wasn't applied; only requests that need a drawn frame blame the window. +#[test] +fn a_request_cancelled_before_it_started_never_runs() { + let gate = RequestGate::new(); + let (_tx, rx) = mpsc::channel::(); + let Err(failure) = wait_for_reply(&rx, &gate, Duration::ZERO) else { panic!("no reply was sent") }; + assert_eq!(failure, WaitFailure::Timeout); + assert!(!gate.start(), "the UI must not run a cancelled request"); + assert_eq!(gate.state(), RequestState::Cancelled); + assert!(timeout_message(&failure, false).contains("not applied")); + assert!(!timeout_message(&failure, false).contains("window on screen")); + assert!(timeout_message(&failure, true).contains("window on screen"), "screenshots and input still need the window"); +} + #[test] fn idle_and_partial_frame_connections_expire() { for bytes in [b"".as_slice(), b"{\"id\":"] { diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index b26639c30..b79eb1652 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -374,6 +374,7 @@ impl Hub { return refuse(&c.out, BARRED); } let hosting = reads_hosting(&hello.client_version); + let packed = message::reads_packed(&hello.client_version); // The backend checked the secret: the sessions don't keep it. hello.token.clear(); let file = hello.file.clone(); @@ -405,7 +406,7 @@ impl Hub { let old = room.ids.insert(id, conn).filter(|old| *old != conn); self.backend.changed(&file, &mut room.server); // The newcomer learns how it is shown, then where everyone is. - let mut more = outs; + let mut more = if packed { packed_welcome(outs) } else { outs }; if let Some(me) = room.server.peers().find(|p| p.client_id == id) { more.push(Outgoing::To(id, ServerMsg::PeerJoined(me.clone()))); } @@ -747,6 +748,19 @@ impl Hub { } } +/// A joining client's messages with the document in its welcome compressed (its hello announced +/// that it reads that). Should compressing fail, the welcome goes as it was. +fn packed_welcome(mut outs: Vec) -> Vec { + for o in &mut outs { + if let Outgoing::To(_, msg) = o + && let Err(e) = message::pack(msg) + { + log::warn!("event=pack_failed error={e}"); + } + } + outs +} + /// Queue `b` for connection `conn`: false when it must be dropped (it can't keep up, or it is /// gone). When the server's budget for queued bytes is used up, the connection holding the most /// queued bytes is shed (added to `shed`) to make room, unless that is `conn` itself: whoever diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index faa9cd8b9..033186af2 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -201,7 +201,13 @@ pub(crate) enum Taken { /// Take in one message from the host: events for the UI go to `inbox`, and `resume` follows the /// session. On the welcome, `welcomed` runs before the UI hears of it: what the sync client sends /// in answer must go out on this connection. -pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken { +pub(crate) fn take(mut m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken { + // A compressed document is read here, within the size the welcome could have had unpacked. + match message::unpack(&mut m, cfg.limits.max_server_message) { + Ok(()) => {} + Err(figcraft_collab::Error::TooLarge(_)) => return Taken::End(too_large(&cfg.limits)), + Err(e) => return Taken::End(Outcome::Refused(format!("the host sent a document this version can't read ({e})"))), + } match m { ServerMsg::Welcome { session, client_id, seq, ref peers, access, secret, .. } => { if let Ok(mut r) = resume.lock() { diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index fe9b47828..30f77bcb3 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -7,7 +7,7 @@ use std::net::{SocketAddr, TcpStream}; use std::sync::Arc; use std::time::{Duration, Instant}; -use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal}; +use figcraft_collab::message::{self, CatchUp, ClientMsg, Hello, Presence, ServerMsg, Signal}; use figcraft_collab::{Client, Limits}; use figcraft_doc::{Document, NodeId}; use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN}; @@ -575,6 +575,23 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() { host.stop(); } +/// The document in a welcome is compressed only for a client whose hello says it reads that; a +/// client from before gets it as it always did (#1665). +#[test] +fn welcomes_are_packed_only_for_clients_that_read_them() { + let (mut host, _hp, invite) = start(); + let addr: SocketAddr = invite.server.host.parse().unwrap(); + let mut old = Raw::connect(addr); + let ClientMsg::Hello(h) = Client::hello(FILE, TOKEN, "Old", "") else { panic!("not a hello") }; + assert!(old.send(&ClientMsg::Hello(Hello { client_version: "0.12.1 signals hosting comments".into(), ..h }))); + let Some(ServerMsg::Welcome { catch_up: CatchUp::Snapshot(plain), .. }) = old.next() else { panic!("no plain welcome") }; + let mut new = Raw::hello(addr, TOKEN); + let Some(mut welcome @ ServerMsg::Welcome { catch_up: CatchUp::Packed(_), .. }) = new.next() else { panic!("no packed welcome") }; + message::unpack(&mut welcome, 64 << 20).unwrap(); + assert!(matches!(welcome, ServerMsg::Welcome { catch_up: CatchUp::Snapshot(s), .. } if s == plain), "the same document"); + host.stop(); +} + #[test] fn signals_reach_only_clients_that_read_them_and_are_rate_limited() { let (mut host, mut hp, invite) = start(); diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 189c1a02c..6d4458e24 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -17,6 +17,7 @@ chrono = { workspace = true } imbl = { workspace = true } base64 = { workspace = true } postcard = { workspace = true } +flate2 = { workspace = true } sha2 = { workspace = true } [dev-dependencies] diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 598a86eab..bcc09ad86 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -227,6 +227,8 @@ pub enum DocProp { Timer, /// A board's vote (`null`: none); the ballots are [`Entry::Ballot`]s. Vote, + /// Ended votes' results kept in the file, oldest first (whole). + VoteHistory, } /// A document-level entry, by id. diff --git a/crates/collab/src/client.rs b/crates/collab/src/client.rs index 19da1a5d6..4d1a8a98a 100644 --- a/crates/collab/src/client.rs +++ b/crates/collab/src/client.rs @@ -560,6 +560,10 @@ impl Client { peers: Vec, recompute: &dyn Recompute, ) -> Result, Error> { + // The transport unpacks a packed document first (`message::unpack`). + if matches!(catch_up, CatchUp::Packed(_)) { + return Err(Error::Protocol("expected the document unpacked".into())); + } let last_batch = processed.last_batch; // An undo or redo lost in flight is made again before it goes out, knowing what others did // meanwhile: its step conflicts with what they wrote while it was in flight, and with what @@ -573,6 +577,7 @@ impl Client { self.presence.clear(); let offline = || "it no longer applied when the connection came back".to_string(); match catch_up { + CatchUp::Packed(_) => {} CatchUp::Delta(batches) => { for acc in batches { if acc.author == self.id { diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index f53040fbf..e203d2c3b 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -269,6 +269,7 @@ doc_scalars! { Kind => kind, Timer => timer, Vote => vote, + VoteHistory => vote_history, } /// Keyed maps (no order): changed and added entries are put, missing ones removed. diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index fd0b9bcbf..8954b20ad 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -65,6 +65,9 @@ pub enum Error { Encode(String), #[error("can't decode: {0}")] Decode(String), + /// A packed document that would grow past this many bytes ([`message::unpack`]). + #[error("the document is over the {0}-byte limit")] + TooLarge(usize), /// The other side broke the protocol (a message out of order or out of place). #[error("protocol error: {0}")] Protocol(String), @@ -151,6 +154,8 @@ mod merge_tests; #[cfg(test)] mod owned_tests; #[cfg(test)] +mod packed_welcome_tests; +#[cfg(test)] mod reconnect_tests; #[cfg(test)] mod rename_tests; diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 01577bce5..8a46cb1de 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -1,6 +1,8 @@ //! Messages between clients and the session server, and their two encodings: a compact binary form //! (a version byte, then postcard) and a JSON form for debugging and agents. +use std::io::{Read, Write}; + use figcraft_doc::NodeId; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -27,9 +29,14 @@ pub const HOSTING: &str = "hosting"; /// reads [`Access::Comment`]: older ones get a comment link as a view link. pub const COMMENTS: &str = "comments"; +/// The word a client puts in its [`Hello::client_version`] when it reads [`CatchUp::Packed`]: a +/// server compresses the document in a welcome only for clients that say so (older ones couldn't +/// read it). +pub const PACKED: &str = "packed"; + /// The `client_version` this build announces: its version, then what it reads beyond the protocol. pub fn client_version() -> String { - format!("{} {SIGNALS} {HOSTING} {COMMENTS}", env!("CARGO_PKG_VERSION")) + format!("{} {SIGNALS} {HOSTING} {COMMENTS} {PACKED}", env!("CARGO_PKG_VERSION")) } /// Whether `client_version` announces `word`. @@ -52,6 +59,11 @@ pub fn reads_comments(client_version: &str) -> bool { announces(client_version, COMMENTS) } +/// A [`Hello::client_version`] that announces [`PACKED`]. +pub fn reads_packed(client_version: &str) -> bool { + announces(client_version, PACKED) +} + /// Join a file's session. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Hello { @@ -317,6 +329,10 @@ pub enum CatchUp { Snapshot(Vec), /// The batches a reconnecting client missed, in order. Delta(Vec), + /// A [`Snapshot`](CatchUp::Snapshot) compressed ([`pack`]), for a client whose hello announced + /// [`PACKED`]: a document's changes shrink several times over. The client reads it with + /// [`unpack`] before anything else sees the welcome. + Packed(Vec), } /// Opens a connection that manages a server's files (instead of [`Hello`]): the server's admin @@ -926,6 +942,11 @@ pub fn decode(bytes: &[u8], max_bytes: usize) -> Result(body: &[u8]) -> Result { let (msg, rest) = postcard::take_from_bytes::(body).map_err(|e| Error::Decode(e.to_string()))?; if !rest.is_empty() { return Err(Error::Decode(format!("{} bytes after the message", rest.len()))); @@ -933,6 +954,43 @@ pub fn decode(bytes: &[u8], max_bytes: usize) -> Result Result<(), Error> { + if let ServerMsg::Welcome { catch_up, .. } = msg + && let CatchUp::Snapshot(changes) = catch_up + { + let raw = postcard::to_allocvec(changes).map_err(|e| Error::Encode(e.to_string()))?; + let mut z = flate2::write::DeflateEncoder::new(Vec::with_capacity(raw.len() / 4), flate2::Compression::fast()); + z.write_all(&raw).map_err(|e| Error::Encode(e.to_string()))?; + *catch_up = CatchUp::Packed(z.finish().map_err(|e| Error::Encode(e.to_string()))?); + } + Ok(()) +} + +/// Undo [`pack`]: a welcome's packed document back as its changes, refused once it would grow +/// past `max_bytes` (untrusted input: the limit an unpacked welcome would have had to fit). +/// Anything else is left as it is. +pub fn unpack(msg: &mut ServerMsg, max_bytes: usize) -> Result<(), Error> { + if let ServerMsg::Welcome { catch_up, .. } = msg + && let CatchUp::Packed(packed) = catch_up + { + let mut raw = Vec::new(); + let limit = u64::try_from(max_bytes).unwrap_or(u64::MAX).saturating_add(1); + flate2::read::DeflateDecoder::new(packed.as_slice()) + .take(limit) + .read_to_end(&mut raw) + .map_err(|e| Error::Decode(format!("packed document: {e}")))?; + if raw.len() > max_bytes { + return Err(Error::TooLarge(max_bytes)); + } + *catch_up = CatchUp::Snapshot(from_postcard(&raw)?); + } + Ok(()) +} + /// The JSON form of `msg` (`{"": {…}}`). pub fn encode_json(msg: &T) -> Result { serde_json::to_string(msg).map_err(|e| Error::Encode(e.to_string())) diff --git a/crates/collab/src/packed_welcome_tests.rs b/crates/collab/src/packed_welcome_tests.rs new file mode 100644 index 000000000..c78208cc4 --- /dev/null +++ b/crates/collab/src/packed_welcome_tests.rs @@ -0,0 +1,114 @@ +//! The document in a welcome goes compressed to clients that read that ([`message::PACKED`], +//! #1665): it shrinks several times over, comes back unchanged, and a hostile packed document +//! can't make the client allocate past the size an unpacked welcome could have had. + +use std::io::Write; + +use figcraft_doc::{Document, NodeKind}; + +use crate::message::{self, CatchUp, ClientMsg, ServerMsg}; +use crate::{Client, Error, Limits, NoRecompute, Outgoing, Replica, Server}; + +/// A design file of 40 screens with 100 cards each, as a server's first welcome. +fn welcome() -> ServerMsg { + let mut doc = Document::new("Synthetic"); + let page = doc.first_page().unwrap(); + for f in 0..40 { + let screen = + doc.create(page, NodeKind::Frame, &format!("Screen {f}"), f64::from(f % 8) * 1500.0, f64::from(f / 8) * 1000.0, 1440.0, 900.0).unwrap(); + for i in 0..100 { + let (x, y) = (f64::from(i % 10) * 140.0, f64::from(i / 10) * 88.0); + doc.create(screen, NodeKind::Rectangle, &format!("Card {i} of screen {f}"), x, y, 128.0, 80.0).unwrap(); + } + } + doc.repair(); + let mut s = Server::new(doc, "tok", Limits::default()); + let ClientMsg::Hello(h) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") }; + let (_, out) = s.join(h).unwrap(); + out.into_iter().find_map(|o| if let Outgoing::To(_, m @ ServerMsg::Welcome { .. }) = o { Some(m) } else { None }).unwrap() +} + +fn with_catch_up(mut msg: ServerMsg, c: CatchUp) -> ServerMsg { + if let ServerMsg::Welcome { catch_up, .. } = &mut msg { + *catch_up = c; + } + msg +} + +fn deflated(bytes: &[u8]) -> Vec { + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(bytes).unwrap(); + z.finish().unwrap() +} + +#[test] +fn a_packed_welcome_is_much_smaller_and_reads_back_the_same() { + let plain = welcome(); + let mut packed = plain.clone(); + message::pack(&mut packed).unwrap(); + assert!(matches!(packed, ServerMsg::Welcome { catch_up: CatchUp::Packed(_), .. })); + let (before, after) = (message::encode(&plain).unwrap().len(), message::encode(&packed).unwrap().len()); + println!("welcome of 4,040 layers: {before} bytes, packed {after} bytes"); + assert!(after * 5 < before, "{before} → {after}"); + let mut back: ServerMsg = message::decode(&message::encode(&packed).unwrap(), after).unwrap(); + message::unpack(&mut back, before).unwrap(); + assert_eq!(back, plain); + // The client opens the same document from it. + let ServerMsg::Welcome { catch_up: CatchUp::Snapshot(changes), .. } = &plain else { panic!("not a snapshot") }; + assert_eq!(Client::join(back).unwrap().document(), Replica::from_snapshot(changes).unwrap().document()); +} + +#[test] +fn only_a_welcomes_whole_document_is_packed() { + for msg in [with_catch_up(welcome(), CatchUp::Delta(Vec::new())), ServerMsg::Pong, ServerMsg::PeerLeft { client_id: 3 }] { + let mut m = msg.clone(); + message::pack(&mut m).unwrap(); + assert_eq!(m, msg, "left as it is"); + message::unpack(&mut m, 0).unwrap(); + assert_eq!(m, msg, "nothing to unpack"); + } +} + +#[test] +fn packed_documents_are_untrusted() { + let mut packed = welcome(); + message::pack(&mut packed).unwrap(); + let ServerMsg::Welcome { catch_up: CatchUp::Snapshot(changes), .. } = welcome() else { panic!("not a snapshot") }; + let size = postcard::to_allocvec(&changes).unwrap().len(); + // Unpacked, it may be no larger than an unpacked welcome could have been. + assert!(matches!(message::unpack(&mut packed.clone(), size - 1), Err(Error::TooLarge(n)) if n == size - 1)); + message::unpack(&mut packed, size).unwrap(); + // A bomb stops at the limit instead of filling memory. + let mut bomb = with_catch_up(welcome(), CatchUp::Packed(deflated(&vec![0; 64 << 20]))); + assert!(matches!(message::unpack(&mut bomb, 1 << 20), Err(Error::TooLarge(_)))); + // Not deflate, not changes, or changes with bytes after them. + let mut good = postcard::to_allocvec(&changes).unwrap(); + for bytes in [ + vec![0xFF; 64], + deflated(b"not a document"), + deflated(&{ + good.push(0); + good + }), + ] { + let mut m = with_catch_up(welcome(), CatchUp::Packed(bytes)); + assert!(matches!(message::unpack(&mut m, usize::MAX), Err(Error::Decode(_))), "{m:?}"); + } +} + +#[test] +fn clients_announce_that_they_read_packed_welcomes() { + assert!(message::reads_packed(&message::client_version())); + assert!(!message::reads_packed("0.12.1 signals hosting comments"), "a client from before"); +} + +/// The sync client reads only what the transport unpacked: a packed document reaching it is a +/// mistake it refuses, on the first welcome and on one after a dropped connection. +#[test] +fn the_sync_client_wants_the_document_unpacked() { + let packed = with_catch_up(welcome(), CatchUp::Packed(Vec::new())); + assert!(Client::join(packed.clone()).is_err()); + let mut c = Client::join(welcome()).unwrap(); + c.disconnect(); + assert!(matches!(c.receive(packed, &NoRecompute), Err(Error::Protocol(_)))); +} diff --git a/crates/doc/src/facilitation.rs b/crates/doc/src/facilitation.rs index fd3ae1d2c..e572742df 100644 --- a/crates/doc/src/facilitation.rs +++ b/crates/doc/src/facilitation.rs @@ -90,6 +90,25 @@ pub struct Vote { /// Every voter's ballot: the objects they put their votes on (once per vote). pub type Ballots = BTreeMap>; +/// The most ended votes a board keeps the results of (the oldest goes first). +pub const VOTE_HISTORY_MAX: usize = 20; + +/// An ended vote's results, kept in the file: the vote, everyone's ballots and the names the +/// voters went by when it ended (a live session's client ids mean nothing once it's over). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoteRecord { + pub vote: Vote, + #[serde(default)] + pub ballots: Ballots, + /// Voter key → name, for the keys that aren't names themselves. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub names: BTreeMap, + /// When it ended (milliseconds since the Unix epoch). + #[serde(default)] + pub ended: u64, +} + impl Vote { /// The votes of `ballot` that count: on objects that are still there, up to the cap. pub fn counted<'a>(&self, doc: &'a Document, ballot: &'a [NodeId]) -> impl Iterator + 'a { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ffa552860..effcdbdbf 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -245,6 +245,9 @@ pub struct Document { /// The vote's ballots, by voter. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub votes: Ballots, + /// Ended votes' results kept in the file, oldest first (at most [`VOTE_HISTORY_MAX`]). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub vote_history: Vec, } impl Default for Document { @@ -287,6 +290,7 @@ impl Document { timer: None, vote: None, votes: BTreeMap::new(), + vote_history: Vec::new(), } } diff --git a/crates/engine/src/assets/library_server.rs b/crates/engine/src/assets/library_server.rs index a55ea5858..ed8f67eaf 100644 --- a/crates/engine/src/assets/library_server.rs +++ b/crates/engine/src/assets/library_server.rs @@ -160,6 +160,12 @@ impl Session { Some(Ok(lib)) => Some(lib.manifest.version), _ => None, }; + // Confined to automation roots, the server isn't asked (#1095). That is no offline + // failure: the last version fetched doesn't stand in for an answer, so `library.updates` + // and `library.list` say the check was refused instead of reporting no updates. + if let Some(why) = crate::file_access::network_refused() { + return Err(EngineError::Other(why)); + } let usage = self.doc().ok().map(|d| { let id = d.libraries.enabled.iter().find(|l| l.path == path).map(|l| l.id.clone()).unwrap_or_default(); usage(d, &id) @@ -213,7 +219,11 @@ impl Session { std::iter::once((l.path.clone(), LibraryReq::Fetch { have, usage: Some(usage(doc, &l.id)) })).chain(report) }) .collect(); - if let (false, Some(net)) = (jobs.is_empty(), self.live.link()) { + // Confined to automation roots, nothing reaches a server (#1095). + if jobs.is_empty() || crate::file_access::network_refused().is_some() { + return; + } + if let Some(net) = self.live.link() { net.library_background(jobs); } } diff --git a/crates/engine/src/automation/file_access.rs b/crates/engine/src/automation/file_access.rs index aeaa3c130..b40a92f79 100644 --- a/crates/engine/src/automation/file_access.rs +++ b/crates/engine/src/automation/file_access.rs @@ -218,6 +218,18 @@ pub fn current() -> Option> { ROOTS.with(|r| r.borrow().clone()) } +/// Why this thread's work may not reach the network now, if it may not (#1095): the automation +/// roots keep an agent to the folders it was given, so while they confine it nothing is published, +/// hosted, joined or fetched from a server either. The engine reaches the network only through the +/// live link (`crate::live::Lives`), which asks this first. +pub fn network_refused() -> Option { + current().map(|_| NETWORK_REFUSED.to_string()) +} + +/// What a confined command that would reach the network says. +pub const NETWORK_REFUSED: &str = + "automation roots are set: commands don't reach the network (publishing, live sessions, team libraries and plugin directories are off)"; + /// The path to use for `access` to `path`: resolved when confined, else as given. pub fn allow(path: impl AsRef, access: Access) -> Result { let path = path.as_ref(); diff --git a/crates/engine/src/canvas/tools/click.rs b/crates/engine/src/canvas/tools/click.rs index cc8a80023..0e07b331d 100644 --- a/crates/engine/src/canvas/tools/click.rs +++ b/crates/engine/src/canvas/tools/click.rs @@ -334,6 +334,10 @@ impl Session { let sel = ds.selection.clone(); if self.tool.pick_only { self.tool.picked = Some(c); + } else if ev.cmd && ev.shift { + // Cmd/Ctrl+Shift: make a colour style or variable of it instead (the UI asks which + // and what to call it; nothing is applied). + self.request_ui("dialog.createFromColor", json!({"color": c.to_css_hex()})); } else if !sel.is_empty() { self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?; } diff --git a/crates/engine/src/cmd/facilitate.rs b/crates/engine/src/cmd/facilitate.rs index 9a1513a60..293d829bf 100644 --- a/crates/engine/src/cmd/facilitate.rs +++ b/crates/engine/src/cmd/facilitate.rs @@ -3,15 +3,16 @@ //! Both live in the document ([`figcraft_doc::facilitation`]) so they reach everyone in a live //! session, and neither is an undo step: undoing an edit leaves the timer and the vote as they are. +use std::collections::BTreeMap; use std::sync::Arc; -use figcraft_doc::{BoardTimer, Document, NodeId, TIMER_MAX_MS, VOTES_MAX, Vote, VoteScope}; +use figcraft_doc::{Ballots, BoardTimer, Document, NodeId, TIMER_MAX_MS, VOTE_HISTORY_MAX, VOTES_MAX, Vote, VoteRecord, VoteScope}; use figcraft_geom::{Point, Rect}; use serde_json::{Value, json}; use crate::board::{require, text_of}; use crate::cmd::CommandSpec; -use crate::params::{id, num, req_string, req_targets, string}; +use crate::params::{id, num, opt_index, req_string, req_targets, string}; use crate::tools::PointerEvent; use crate::{EngineError, Result, Session, bad, spec}; @@ -47,19 +48,19 @@ pub fn specs() -> Vec { spec!( "board.vote", "Voting session", - "{action: start|cast|retract|end|clear, votes? (start: per person, 1–99, default 3), on? (start: all|stickies|selection; default all), ids? (start with on: selection; default the selection), id (cast, retract: the object), voter? (default you)} → the vote's state (as board.voteResults) one vote at a time; others' votes show once it ends", + "{action: start|cast|retract|end|clear|delete, votes? (start: per person, 1–99, default 3), on? (start: all|stickies|selection; default all), ids? (start with on: selection; default the selection), id (cast, retract: the object), index (delete: which kept results, 0 the oldest), voter? (default you)} → the vote's state (as board.voteResults) one vote at a time; others' votes show once it ends, and its results are kept in the file (the last 20 votes'); clear takes the results off the board, delete removes kept ones", false, vote_cmd ), spec!( "board.voteResults", "Voting results", - "{voter?} → {state: off|running|ended, votesPerPerson, on, by, left (your votes left), mine [{id, votes}], voters, results? [{id, name, votes, rank}] (once ended, most votes first)}", + "{voter?} → {state: off|running|ended, votesPerPerson, on, by, left (your votes left), mine [{id, votes}], voters, results? [{id, name, votes, rank, voters: [{voter, name (null: someone who left the live session), votes}]}] (once ended, most votes first, with who voted for each), sessions [{index (in the kept results, 0 the oldest; null for one not kept), onBoard, ended (ms since 1970), votesPerPerson, on, by, results}] (every ended vote's results, newest first)}", false, |s, p| { require(s, "board.voteResults")?; let who = voter(s, p); - Ok(vote_json(s.doc()?, &who)) + vote_json(s, &who) } ), ] @@ -176,12 +177,7 @@ fn vote_cmd(s: &mut Session, p: &Value) -> Result { match action.as_str() { "start" => start_vote(s, p, CMD)?, "cast" | "retract" => change_ballot(s, p, CMD, &who, action == "cast")?, - "end" => { - running(CMD, s.doc()?)?; - if let Some(v) = s.doc_mut()?.vote.as_mut() { - v.ended = true; - } - } + "end" => end_vote(s, CMD)?, "clear" => { let doc = s.doc()?; if doc.vote.is_some() || !doc.votes.is_empty() { @@ -190,9 +186,50 @@ fn vote_cmd(s: &mut Session, p: &Value) -> Result { doc.votes.clear(); } } - other => return Err(bad(CMD, format!("unknown action `{other}` (start|cast|retract|end|clear)"))), + "delete" => delete_results(s, p, CMD)?, + other => return Err(bad(CMD, format!("unknown action `{other}` (start|cast|retract|end|clear|delete)"))), } - Ok(vote_json(s.doc()?, &who)) + vote_json(s, &who) +} + +/// `board.vote {action: end}`: everyone's votes show, and the results are kept in the file with +/// the names the voters go by now (the last [`VOTE_HISTORY_MAX`] votes'). +fn end_vote(s: &mut Session, cmd: &str) -> Result<()> { + running(cmd, s.doc()?)?; + let names = s.doc()?.votes.keys().filter_map(|k| voter_name(s, k).filter(|n| n != k).map(|n| (k.clone(), n))).collect(); + let ended = now_ms(); + let doc = s.doc_mut()?; + if let Some(v) = doc.vote.as_mut() { + v.ended = true; + doc.vote_history.push(VoteRecord { vote: v.clone(), ballots: doc.votes.clone(), names, ended }); + let extra = doc.vote_history.len().saturating_sub(VOTE_HISTORY_MAX); + doc.vote_history.drain(..extra); + } + Ok(()) +} + +/// `board.vote {action: delete, index}`: kept results deleted from the file (`index` 0 the +/// oldest, as `board.voteResults` lists them); those on the board now are cleared from it too. +fn delete_results(s: &mut Session, p: &Value, cmd: &str) -> Result<()> { + let doc = s.doc()?; + if doc.vote_history.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "no results are kept".into())); + } + let last = doc.vote_history.len() - 1; + let index = opt_index(cmd, p, "index")?.filter(|i| *i <= last).ok_or_else(|| bad(cmd, format!("`index` is from 0 to {last}")))?; + let on_board = doc.vote_history.get(index).is_some_and(|r| on_board(doc, r)); + let doc = s.doc_mut()?; + doc.vote_history.remove(index); + if on_board { + doc.vote = None; + doc.votes.clear(); + } + Ok(()) +} + +/// Whether kept results `r` are the ended vote on the board now. +fn on_board(doc: &Document, r: &VoteRecord) -> bool { + doc.vote.as_ref().is_some_and(|v| v.ended && *v == r.vote) && doc.votes == r.ballots } /// `board.vote {action: start}`: a new vote (votes per person, on what), clearing the last results. @@ -280,13 +317,119 @@ pub fn object_name(doc: &Document, id: NodeId) -> String { if short.len() < name.len() { format!("{short}…") } else { short } } -fn vote_json(doc: &Document, who: &str) -> Value { - let Some(vote) = &doc.vote else { return json!({"state": "off"}) }; - let on = match &vote.scope { +/// Who put votes on `id` in `ballots`, and how many each, by voter (as the ballots key them). +pub fn voters_of(doc: &Document, vote: &Vote, ballots: &Ballots, id: NodeId) -> Vec<(String, u32)> { + ballots + .iter() + .filter_map(|(who, ballot)| { + let n = vote.counted(doc, ballot).filter(|v| *v == id).count(); + (n > 0).then(|| (who.clone(), u32::try_from(n).unwrap_or(u32::MAX))) + }) + .collect() +} + +/// One object in an ended vote's results. +#[derive(Clone, Debug, PartialEq)] +pub struct ResultRow { + /// Ties share a rank ("1, 2, 2, 4"). + pub rank: usize, + pub id: NodeId, + /// The sticky's text or the layer's name ([`object_name`]). + pub name: String, + pub votes: u32, + /// Who voted for it: their key, the name they go by (`None`: someone who left a live session + /// before the vote ended) and their votes on it. + pub voters: Vec<(String, Option, u32)>, +} + +/// An ended vote's results: kept in the file (`index` in [`Document::vote_history`]), on the +/// board now (`on_board`: its badges show), or both. +#[derive(Clone, Debug, PartialEq)] +pub struct VoteSession { + pub index: Option, + pub on_board: bool, + /// When it ended (ms since 1970; 0 when not known). + pub ended: u64, + pub vote: Vote, + pub rows: Vec, +} + +/// `ballots`' results under `vote`, most votes first; voters named from `names` (as they were +/// when it ended), else as the session knows them now ([`voter_name`]). +fn result_rows(s: &Session, doc: &Document, vote: &Vote, ballots: &Ballots, names: &BTreeMap) -> Vec { + let name = |key: &str| names.get(key).cloned().or_else(|| voter_name(s, key)); + ranked(doc, vote, ballots) + .into_iter() + .map(|(rank, id, votes)| { + let voters = voters_of(doc, vote, ballots, id).into_iter().map(|(key, n)| (key.clone(), name(&key), n)).collect(); + ResultRow { rank, id, name: object_name(doc, id), votes, voters } + }) + .collect() +} + +/// Every ended vote's results, newest first: those the file keeps, and the ended vote on the +/// board when the file doesn't keep it (one that ended before results were kept). +pub fn sessions(s: &Session) -> Result> { + let doc = s.doc()?; + let mut out: Vec = doc + .vote_history + .iter() + .enumerate() + .map(|(i, r)| VoteSession { + index: Some(i), + on_board: on_board(doc, r), + ended: r.ended, + vote: r.vote.clone(), + rows: result_rows(s, doc, &r.vote, &r.ballots, &r.names), + }) + .collect(); + if let Some(vote) = doc.vote.as_ref().filter(|v| v.ended) + && !out.iter().any(|x| x.on_board) + { + let rows = result_rows(s, doc, vote, &doc.votes, &BTreeMap::new()); + out.push(VoteSession { index: None, on_board: true, ended: 0, vote: vote.clone(), rows }); + } + out.reverse(); + Ok(out) +} + +/// The name voter `key` goes by: in a live session a client id's ("peer 3") is the person's +/// name there (yours or another's); `None` for one who isn't in the session any more. Any other +/// key is a name already (yours offline, or a `voter` given to `board.vote`). +pub fn voter_name(s: &Session, key: &str) -> Option { + let Some(client) = key.strip_prefix("peer ").and_then(|n| n.parse::().ok()) else { return Some(key.to_string()) }; + let live = s.ds().ok().filter(|ds| s.live_doc(ds.uid)).map(|ds| s.live_of(ds.uid))?; + match &live.info { + Some(info) if info.me == client => Some(info.name.clone()), + _ => live.peers.get(&client).map(|p| p.name.clone()), + } +} + +fn scope_json(scope: &VoteScope) -> Value { + match scope { VoteScope::All => json!("all"), VoteScope::Stickies => json!("stickies"), VoteScope::Only { ids } => json!({"selection": ids}), - }; + } +} + +fn rows_json(rows: &[ResultRow]) -> Value { + let voters = |r: &ResultRow| r.voters.iter().map(|(key, name, n)| json!({"voter": key, "name": name, "votes": n})).collect::>(); + json!(rows.iter().map(|r| json!({"id": r.id, "name": r.name, "votes": r.votes, "rank": r.rank, "voters": voters(r)})).collect::>()) +} + +fn vote_json(s: &Session, who: &str) -> Result { + let sessions = sessions(s)?; + let kept: Vec = sessions + .iter() + .map(|x| { + json!({"index": x.index, "onBoard": x.on_board, "ended": x.ended, "votesPerPerson": x.vote.per_person, + "on": scope_json(&x.vote.scope), "by": x.vote.by, "results": rows_json(&x.rows)}) + }) + .collect(); + let doc = s.doc()?; + let Some(vote) = &doc.vote else { return Ok(json!({"state": "off", "sessions": kept})) }; + let on = scope_json(&vote.scope); let mine = mine(doc, who); let used: u32 = mine.iter().map(|m| m.1).sum(); let voters = doc.votes.values().filter(|b| vote.counted(doc, b).next().is_some()).count(); @@ -298,20 +441,21 @@ fn vote_json(doc: &Document, who: &str) -> Value { "left": vote.per_person.saturating_sub(used), "mine": mine.iter().map(|(id, n)| json!({"id": id, "votes": n})).collect::>(), "voters": voters, + "sessions": kept, }); - if vote.ended { - let results: Vec = - ranked(doc, vote).into_iter().map(|(rank, id, n)| json!({"id": id, "name": object_name(doc, id), "votes": n, "rank": rank})).collect(); - out["results"] = json!(results); + // Who voted for what shows once the vote ends; until then each person sees only their own. + if let Some(on_board) = sessions.iter().find(|x| x.on_board).filter(|_| vote.ended) { + out["results"] = rows_json(&on_board.rows); } - out + Ok(out) } -/// The objects with votes, most first, each with its rank (ties share one: "1, 2, 2, 4"). -pub fn ranked(doc: &Document, vote: &Vote) -> Vec<(usize, NodeId, u32)> { +/// The objects with votes in `ballots`, most first, each with its rank (ties share one: "1, 2, +/// 2, 4"). +pub fn ranked(doc: &Document, vote: &Vote, ballots: &Ballots) -> Vec<(usize, NodeId, u32)> { let mut last = None; let mut rank = 0; - vote.tally(doc, &doc.votes) + vote.tally(doc, ballots) .into_iter() .enumerate() .map(|(i, (id, n))| { @@ -361,13 +505,14 @@ impl Session { } } -/// Undo and redo bring back a document as it was, but the timer and the vote aren't undoable: -/// keep them as they are now (`from`). +/// Undo and redo bring back a document as it was, but the timer, the vote and the kept results +/// aren't undoable: keep them as they are now (`from`). pub(crate) fn keep(from: &Document, to: &mut Arc) { - if from.timer != to.timer || from.vote != to.vote || from.votes != to.votes { + if from.timer != to.timer || from.vote != to.vote || from.votes != to.votes || from.vote_history != to.vote_history { let d = Arc::make_mut(to); d.timer = from.timer; d.vote = from.vote.clone(); d.votes = from.votes.clone(); + d.vote_history = from.vote_history.clone(); } } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 08f07d4ac..c1ce412c4 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -594,6 +594,11 @@ fn check(s: &mut Session, p: &Value) -> Result { } None => doc.libraries.enabled.iter().filter(|l| is_server_link(&l.path)).map(|l| l.path.clone()).collect(), }; + // Confined to automation roots, nothing is checked (#1095): say so, and don't remember the + // libraries as yours either. + if let (false, Some(why)) = (links.is_empty(), crate::file_access::network_refused()) { + return Err(EngineError::Disabled(cmd.to_string(), why)); + } for l in &links { s.allow_library(l); } diff --git a/crates/engine/src/eyedropper_create_tests.rs b/crates/engine/src/eyedropper_create_tests.rs new file mode 100644 index 000000000..5dbc82ddd --- /dev/null +++ b/crates/engine/src/eyedropper_create_tests.rs @@ -0,0 +1,66 @@ +//! The eyedropper with Cmd/Ctrl+Shift held makes a colour style or variable of what it picks (as +//! the reference app's eyedropper does): it asks the UI which (`dialog.createFromColor`) and leaves the +//! selection's fills alone; a plain pick still fills the selection. + +use figcraft_doc::NodeId; +use serde_json::json; + +use crate::Session; +use crate::tools::{PointerEvent, PointerKind, ToolId}; + +/// A white page with a red square at (0, 0) and a green one selected at (200, 0). +fn scene() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap(); + let rect = |s: &mut Session, x: f64, fill: &str| { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap(); + let id = crate::params::id_of(&r["id"]).unwrap(); + s.execute("node.set", &json!({"id": id, "props": {"fills": [fill]}})).unwrap(); + id + }; + rect(&mut s, 0.0, "#FF0000"); + let green = rect(&mut s, 200.0, "#00FF00"); + s.execute("select.set", &json!({"ids": [green]})).unwrap(); + s.ui_requests.clear(); + (s, green) +} + +fn pick(s: &mut Session, cmd: bool, shift: bool) { + s.execute("edit.pickColor", &json!({})).unwrap(); + s.pointer(&PointerEvent { cmd, shift, ..PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0) }).unwrap(); +} + +fn fill(s: &Session, id: NodeId) -> Option { + s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()) +} + +#[test] +fn cmd_shift_asks_to_make_a_style_or_variable_of_the_pick() { + let (mut s, green) = scene(); + let undo = s.undo_depth().0; + pick(&mut s, true, true); + let asks: Vec<_> = s.ui_requests.iter().filter(|r| r.kind == "dialog.createFromColor").collect(); + assert_eq!(asks.len(), 1, "{:?}", s.ui_requests); + assert_eq!(asks[0].data["color"], "#FF0000", "the colour under the pointer"); + assert_eq!(fill(&s, green), Some("00FF00".into()), "the selection keeps its fill"); + assert_eq!(s.undo_depth().0, undo, "nothing to undo"); + assert_eq!(s.tool.tool, ToolId::Move, "picking ends the eyedropper"); + assert_eq!(s.recent_colors.first().map(|c| c.to_hex()), Some("FF0000".into()), "a recent colour as any pick"); +} + +#[test] +fn other_picks_fill_the_selection_as_before() { + for (cmd, shift) in [(false, false), (true, false), (false, true)] { + let (mut s, green) = scene(); + pick(&mut s, cmd, shift); + assert!(!s.ui_requests.iter().any(|r| r.kind == "dialog.createFromColor"), "cmd {cmd} shift {shift}"); + assert_eq!(fill(&s, green), Some("FF0000".into()), "cmd {cmd} shift {shift}"); + } + // The colour picker's eyedropper only samples, whatever is held. + let (mut s, green) = scene(); + s.execute("edit.pickColor", &json!({"apply": false})).unwrap(); + s.pointer(&PointerEvent { cmd: true, shift: true, ..PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0) }).unwrap(); + assert!(s.ui_requests.is_empty()); + assert_eq!(s.tool.picked.map(|c| c.to_hex()), Some("FF0000".into())); + assert_eq!(fill(&s, green), Some("00FF00".into())); +} diff --git a/crates/engine/src/facilitate_tests.rs b/crates/engine/src/facilitate_tests.rs index 1db0b67ac..2ad4d9f87 100644 --- a/crates/engine/src/facilitate_tests.rs +++ b/crates/engine/src/facilitate_tests.rs @@ -132,12 +132,14 @@ fn votes_are_capped_hidden_until_the_end_and_ranked() { assert!(s.execute("board.vote", &json!({"action": "cast", "id": b, "voter": "Dee"})).is_err(), "ended"); let r = ex(&mut s, "board.voteResults", json!({})); assert_eq!(r["state"], "ended"); + // Once it ends, everyone sees who voted for what. + let voter = |name: &str, votes: u32| json!({"voter": name, "name": name, "votes": votes}); assert_eq!( r["results"], json!([ - {"id": a.0, "name": "Ship it", "votes": 3, "rank": 1}, - {"id": b.0, "name": "Wait", "votes": 2, "rank": 2}, - {"id": c.0, "name": "Rethink", "votes": 1, "rank": 3}, + {"id": a.0, "name": "Ship it", "votes": 3, "rank": 1, "voters": [voter("Ada", 2), voter("Cy", 1)]}, + {"id": b.0, "name": "Wait", "votes": 2, "rank": 2, "voters": [voter("Bo", 1), voter("Cy", 1)]}, + {"id": c.0, "name": "Rethink", "votes": 1, "rank": 3, "voters": [voter("Bo", 1)]}, ]) ); // A deleted object's votes no longer count. @@ -238,10 +240,26 @@ fn everyone_in_a_live_session_shares_the_timer_and_the_vote() { room.check(); assert_eq!(room.server.document().votes.len(), 2, "two voters, apart though both are called the same"); assert_eq!(ex(room.s(0), "board.voteResults", json!({}))["mine"], json!([{"id": a.0, "votes": 1}])); - ex(room.s(1), "board.vote", json!({"action": "end"})); + // Who voted shows by the names people go by in the session, kept with the results when it + // ends (this room delivers no join messages, so peer 2 is added as one would add them). + let me = room.s(0).live_mut().and_then(|l| l.info.as_ref()).map(|i| i.name.clone()).expect("in the session"); + let peer2 = figcraft_collab::Peer { client_id: 2, name: "Grace".into(), color: "#00AA88".into() }; + room.s(0).live_mut().expect("in the session").peers.insert(2, peer2); + ex(room.s(0), "board.vote", json!({"action": "end"})); room.settle(); - let r = ex(room.s(0), "board.voteResults", json!({})); - assert_eq!(r["results"], json!([{"id": a.0, "name": "A", "votes": 2, "rank": 1}, {"id": b.0, "name": "B", "votes": 1, "rank": 2}])); + room.check(); + let grace = json!({"voter": "peer 2", "name": "Grace", "votes": 1}); + let want = json!([ + {"id": a.0, "name": "A", "votes": 2, "rank": 1, "voters": [{"voter": "peer 1", "name": me, "votes": 1}, grace]}, + {"id": b.0, "name": "B", "votes": 1, "rank": 2, "voters": [grace]}, + ]); + for i in 0..2 { + assert_eq!(ex(room.s(i), "board.voteResults", json!({}))["results"], want, "everyone sees them ({i})"); + } + // Someone who has left keeps the name the results were kept with; their votes still count. + room.s(0).live_mut().expect("in the session").peers.remove(&2); + assert_eq!(ex(room.s(0), "board.voteResults", json!({}))["results"], want); + assert_eq!(room.server.document().vote_history.len(), 1, "the results are kept for everyone"); // Undo in a session takes back your own edits only, not the vote. room.s(0).undo().unwrap(); room.settle(); @@ -292,6 +310,11 @@ fn facilitation_commands_survive_junk() { json!({"action": "end"}), json!({"action": "clear", "ids": 7}), json!({"action": "resume", "minutes": [1]}), + json!({"action": "delete"}), + json!({"action": "delete", "index": -1}), + json!({"action": "delete", "index": 1e308}), + json!({"action": "delete", "index": "0"}), + json!({"action": "delete", "index": 0}), ]; for cmd in ["board.timer", "board.timerState", "board.vote", "board.voteResults"] { for j in &junk { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e5ce7f8bb..62950bb6e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -309,6 +309,8 @@ mod export_depth_tests; #[cfg(test)] mod extended_chain_tests; #[cfg(test)] +mod eyedropper_create_tests; +#[cfg(test)] mod facilitate_tests; #[cfg(test)] mod fig_export_assets_tests; @@ -607,6 +609,8 @@ mod version_tests; #[cfg(test)] mod view_behaviour_tests; #[cfg(test)] +mod vote_history_tests; +#[cfg(test)] mod vue_codegen_tests; #[cfg(test)] mod widget_hover_tests; diff --git a/crates/engine/src/library_beacon_tests.rs b/crates/engine/src/library_beacon_tests.rs index 187ea5264..43629005f 100644 --- a/crates/engine/src/library_beacon_tests.rs +++ b/crates/engine/src/library_beacon_tests.rs @@ -115,3 +115,27 @@ fn checking_one_team_library_by_name() { assert_eq!(sent(&wire), vec![LINK.to_string()]); let _ = std::fs::remove_file(path); } + +/// Confined to automation roots (#1095), checking says it was refused instead of counting +/// libraries it never asked about, and doesn't remember them as yours: unconfined later, they are +/// still not asked by themselves. +#[test] +fn a_confined_check_is_refused_before_the_libraries_are_remembered() { + let (mut s, wire, path) = opened(Prefs::default(), "confined"); + let dir = std::env::temp_dir().join(format!("figcraft-beacon-roots-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let roots = Arc::new(crate::file_access::Roots::new(&[], &[dir.to_string_lossy().into_owned()]).unwrap()); + crate::file_access::confined(Some(&roots), || { + for p in [json!({}), json!({"library": "Kit"})] { + let e = s.execute("library.check", &p).unwrap_err(); + assert!(matches!(e, crate::EngineError::Disabled(..)), "{e}"); + assert!(e.to_string().contains(crate::file_access::NETWORK_REFUSED), "{e}"); + } + }); + assert!(sent(&wire).is_empty(), "nothing was asked: {:?}", sent(&wire)); + assert!(!s.library_allowed(LINK) && !s.live.unsaved, "not remembered"); + s.refresh_server_libraries(); + assert!(sent(&wire).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_file(path); +} diff --git a/crates/engine/src/sharing/background.rs b/crates/engine/src/sharing/background.rs index 1a29c2d19..5200bfd9d 100644 --- a/crates/engine/src/sharing/background.rs +++ b/crates/engine/src/sharing/background.rs @@ -128,6 +128,9 @@ impl Session { /// answered when it was an earlier one (what it carried then). pub(crate) fn net(&mut self, req: NetRequest) -> Result<(NetReply, Option)> { let Some(link) = self.live.link() else { return Err(EngineError::Other(req.unavailable().into())) }; + if let Some(why) = crate::file_access::network_refused() { + return Err(EngineError::Other(why)); + } if let Some(run) = self.background.run.as_mut() { let earlier = run.answers.iter().zip(run.used.iter_mut()).find(|((sent, _), used)| !**used && sent.stands_for(&req)); if let Some(((sent, reply), used)) = earlier { diff --git a/crates/engine/src/sharing/background/tests.rs b/crates/engine/src/sharing/background/tests.rs index f7a995cfd..9ab0ce1d1 100644 --- a/crates/engine/src/sharing/background/tests.rs +++ b/crates/engine/src/sharing/background/tests.rs @@ -270,3 +270,65 @@ fn a_link_without_background_requests_is_waited_on() { let mut bare = Session::new(); assert!(bare.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap_err().to_string().contains("desktop app")); } + +/// Confined to automation roots, commands reach no server (#1095): browsing a plugin directory, +/// hosting, joining and publishing are refused before the link is asked. Unconfined, the same +/// request reaches the server as before. +#[test] +fn commands_confined_to_automation_roots_reach_no_server() { + let dir = std::env::temp_dir().join(format!("figcraft-roots-network-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let roots = Arc::new(crate::file_access::Roots::new(&[], &[dir.to_string_lossy().into_owned()]).unwrap()); + let (mut s, wire) = session(false); + let refused = |r: Result| { + let e = r.unwrap_err().to_string(); + assert!(e.contains(crate::file_access::NETWORK_REFUSED), "{e}"); + }; + crate::file_access::confined(Some(&roots), || { + refused(s.execute("plugin.directory.list", &json!({"server": SERVER}))); + refused(s.execute("collab.host", &json!({}))); + refused(s.execute("collab.join", &json!({"link": LINK}))); + refused(s.execute("collab.publish", &json!({"server": SERVER}))); + }); + assert_eq!(wire.lock().unwrap().waited, 0, "the server was never asked"); + assert!(s.live.iter().next().is_none(), "no session was started"); + let _ = std::fs::remove_dir_all(&dir); + + let r = s.execute("plugin.directory.list", &json!({"server": SERVER})).unwrap(); + assert_eq!(r["plugins"][0]["id"], ID); + assert_eq!(wire.lock().unwrap().waited, 1); + let e = s.execute("collab.host", &json!({})).unwrap_err().to_string(); + assert!(!e.contains(crate::file_access::NETWORK_REFUSED), "unconfined, the link is asked: {e}"); +} + +/// A team library fetched before stays cached, but confined to automation roots (#1095) checking +/// it for updates is refused, and `library.updates` and `library.list` say so instead of +/// answering from the cache as if the server had nothing newer. +#[test] +fn a_confined_update_check_reports_the_refusal_not_the_cached_library() { + let (mut s, wire) = session(false); + wire.lock().unwrap().library = Some(published()); + s.execute("library.add", &json!({"path": LINK})).unwrap(); + assert!(matches!(s.libraries.cached(LINK), Some(Ok(_)))); + let waited = wire.lock().unwrap().waited; + let dir = std::env::temp_dir().join(format!("figcraft-roots-updates-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let roots = Arc::new(crate::file_access::Roots::new(&[], &[dir.to_string_lossy().into_owned()]).unwrap()); + crate::file_access::confined(Some(&roots), || { + let u = s.execute("library.updates", &json!({})).unwrap(); + assert_eq!(u["count"], 0); + let e = u["errors"][0]["error"].as_str().unwrap(); + assert!(e.contains(crate::file_access::NETWORK_REFUSED), "{u}"); + let l = s.execute("library.list", &json!({})).unwrap(); + let e = l["libraries"][0]["error"].as_str().unwrap(); + assert!(e.contains(crate::file_access::NETWORK_REFUSED), "{l}"); + }); + assert_eq!(wire.lock().unwrap().waited, waited, "the server was never asked"); + assert!(matches!(s.libraries.cached(LINK), Some(Ok(_))), "the cached library is kept"); + let _ = std::fs::remove_dir_all(&dir); + + // Unconfined, the same check reaches the server and finds nothing new. + let u = s.execute("library.updates", &json!({})).unwrap(); + assert_eq!((u["count"].clone(), u["errors"].as_array().map(Vec::len)), (json!(0), Some(0)), "{u}"); + assert_eq!(wire.lock().unwrap().waited, waited + 1); +} diff --git a/crates/engine/src/sharing/live.rs b/crates/engine/src/sharing/live.rs index a0b98c631..45d56776c 100644 --- a/crates/engine/src/sharing/live.rs +++ b/crates/engine/src/sharing/live.rs @@ -274,6 +274,9 @@ impl Lives { /// session's id, which comes back with what it returned. fn start(&mut self, cmd: &str, start: impl FnOnce(&mut dyn LiveLink, SessionId) -> Result) -> Result<(SessionId, T)> { let link = self.link.as_deref_mut().ok_or_else(|| unavailable(cmd))?; + if let Some(why) = crate::file_access::network_refused() { + return Err(EngineError::Disabled(cmd.to_string(), why)); + } let id = self.last.saturating_add(1); let out = start(link, id)?; self.last = id; diff --git a/crates/engine/src/sharing/team.rs b/crates/engine/src/sharing/team.rs index 91b3891c0..2dd28eeeb 100644 --- a/crates/engine/src/sharing/team.rs +++ b/crates/engine/src/sharing/team.rs @@ -99,7 +99,7 @@ impl Session { pub(crate) fn team_refresh(&mut self) -> Result<()> { let server = plain_server(&self.prefs.publish_server); let public = option_env!("FIGCRAFT_DEFAULT_SERVER").is_none() && same_server(&server, DEFAULT_SERVER); - if server.is_empty() || public || self.live.link().is_none() { + if server.is_empty() || public || self.live.link().is_none() || crate::file_access::network_refused().is_some() { self.live.team = None; return Ok(()); } diff --git a/crates/engine/src/vote_history_tests.rs b/crates/engine/src/vote_history_tests.rs new file mode 100644 index 000000000..0dac2f2ab --- /dev/null +++ b/crates/engine/src/vote_history_tests.rs @@ -0,0 +1,152 @@ +//! Earlier votes' results kept in the board file (the reference app keeps each voting session's +//! results until they're deleted): recorded when a vote ends, at most [`VOTE_HISTORY_MAX`], listed +//! newest first, deleted one at a time, saved, carried over undo, and opened from older files. + +use figcraft_doc::{NodeId, VOTE_HISTORY_MAX, Vote, VoteRecord, VoteScope}; +use serde_json::{Value, json}; + +use crate::board_tests::{board, id}; +use crate::{EngineError, Session}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn sticky(s: &mut Session, x: f64, text: &str) -> NodeId { + id(&ex(s, "board.sticky", json!({"x": x, "y": 0, "text": text})), "id") +} + +/// A vote of `votes` (voter, object) pairs, ended. +fn vote(s: &mut Session, votes: &[(&str, NodeId)]) { + ex(s, "board.vote", json!({"action": "start", "votes": 3})); + for (who, on) in votes { + ex(s, "board.vote", json!({"action": "cast", "id": on, "voter": who})); + } + ex(s, "board.vote", json!({"action": "end"})); +} + +/// A listed session: its index, whether it's on the board, and its objects' votes (id, votes). +type Listed = (Value, bool, Vec<(u64, u64)>); + +fn sessions(s: &mut Session) -> Vec { + let r = ex(s, "board.voteResults", json!({})); + r["sessions"] + .as_array() + .unwrap() + .iter() + .map(|x| { + let rows = x["results"].as_array().unwrap().iter().map(|r| (r["id"].as_u64().unwrap(), r["votes"].as_u64().unwrap())).collect(); + (x["index"].clone(), x["onBoard"].as_bool().unwrap(), rows) + }) + .collect() +} + +#[test] +fn ended_votes_are_kept_newest_first_and_the_last_twenty() { + let mut s = board(); + let (a, b) = (sticky(&mut s, 0.0, "A"), sticky(&mut s, 300.0, "B")); + vote(&mut s, &[("Ada", a), ("Bo", a)]); + vote(&mut s, &[("Ada", b)]); + assert_eq!( + sessions(&mut s), + vec![(json!(1), true, vec![(b.0, 1)]), (json!(0), false, vec![(a.0, 2)])], + "the vote on the board first, then the one before" + ); + let kept = &s.doc().unwrap().vote_history; + assert!(kept.iter().all(|r| r.vote.ended && r.ended > 0), "ended, with when"); + // Clearing takes the results off the board; starting a vote leaves the kept ones too. + ex(&mut s, "board.vote", json!({"action": "clear"})); + assert_eq!(sessions(&mut s).iter().filter(|x| x.1).count(), 0, "none on the board"); + assert_eq!(sessions(&mut s).len(), 2, "still kept"); + ex(&mut s, "board.vote", json!({"action": "start"})); + assert_eq!(ex(&mut s, "board.voteResults", json!({}))["sessions"].as_array().map(Vec::len), Some(2), "kept while a vote runs"); + ex(&mut s, "board.vote", json!({"action": "end"})); + for _ in 0..VOTE_HISTORY_MAX + 2 { + vote(&mut s, &[("Cy", a)]); + } + assert_eq!(s.doc().unwrap().vote_history.len(), VOTE_HISTORY_MAX, "the oldest go"); + assert!(s.doc().unwrap().vote_history.iter().all(|r| r.ballots.contains_key("Cy")), "the first two are gone"); +} + +#[test] +fn deleting_kept_results() { + let mut s = board(); + let (a, b) = (sticky(&mut s, 0.0, "A"), sticky(&mut s, 300.0, "B")); + assert!(matches!(s.execute("board.vote", &json!({"action": "delete", "index": 0})), Err(EngineError::Disabled(..))), "none kept"); + vote(&mut s, &[("Ada", a)]); + vote(&mut s, &[("Bo", b)]); + assert!(s.execute("board.vote", &json!({"action": "delete", "index": 2})).is_err(), "no third"); + assert!(s.execute("board.vote", &json!({"action": "delete"})).is_err(), "which one"); + assert!(s.execute("board.vote", &json!({"action": "undelete", "index": 0})).is_err(), "no such action"); + // An earlier one: the vote on the board stays. + ex(&mut s, "board.vote", json!({"action": "delete", "index": 0})); + assert_eq!(sessions(&mut s), vec![(json!(0), true, vec![(b.0, 1)])]); + assert!(s.doc().unwrap().vote.is_some()); + // The one on the board: it leaves the board too. + ex(&mut s, "board.vote", json!({"action": "delete", "index": 0})); + let doc = s.doc().unwrap(); + assert!(doc.vote_history.is_empty() && doc.vote.is_none() && doc.votes.is_empty()); + assert_eq!(ex(&mut s, "board.voteResults", json!({})), json!({"state": "off", "sessions": []})); +} + +#[test] +fn kept_results_save_and_older_files_open() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + vote(&mut s, &[("Ada", a)]); + let doc = s.doc().unwrap().clone(); + let bytes = figcraft_format::save_bytes(&doc, None).unwrap(); + let (back, _) = figcraft_format::load_bytes(&bytes).unwrap(); + assert_eq!(back.vote_history, doc.vote_history); + // A file from before results were kept: none listed but the ended vote on its board, which + // shows and clears as before (it can't be deleted: it isn't kept). + let mut v = figcraft_format::to_json(&doc).unwrap(); + assert!(v.as_object_mut().unwrap().remove("vote_history").is_some(), "saved as vote_history"); + let (old, _) = figcraft_format::from_json(&v.to_string()).unwrap(); + assert!(old.vote_history.is_empty() && old.vote.is_some()); + let mut s = board(); + s.ds_mut().unwrap().doc = std::sync::Arc::new(old); + assert_eq!(sessions(&mut s), vec![(Value::Null, true, vec![(a.0, 1)])]); + ex(&mut s, "board.vote", json!({"action": "clear"})); + assert!(sessions(&mut s).is_empty()); + // Design files don't write it. + assert!(figcraft_format::to_json(&figcraft_doc::Document::new("D")).unwrap().get("vote_history").is_none()); +} + +#[test] +fn undo_leaves_kept_results_alone() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + vote(&mut s, &[("Ada", a)]); + let b = sticky(&mut s, 300.0, "B"); + vote(&mut s, &[("Bo", b)]); + s.undo().unwrap(); + assert!(s.doc().unwrap().node(b).is_none(), "the sticky is undone"); + assert_eq!(s.doc().unwrap().vote_history.len(), 2, "the results ended since stay"); + ex(&mut s, "board.vote", json!({"action": "delete", "index": 0})); + s.redo().unwrap(); + assert_eq!(s.doc().unwrap().vote_history.len(), 1, "redo doesn't bring deleted results back"); +} + +#[test] +fn voters_go_by_the_names_kept_with_the_results() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + let record = |names: &[(&str, &str)]| VoteRecord { + vote: Vote { per_person: 3, scope: VoteScope::All, ended: true, by: String::new() }, + ballots: [("peer 7".to_string(), vec![a]), ("peer 9".to_string(), vec![a])].into(), + names: names.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), + ended: 1, + }; + s.ds_mut().unwrap().doc = { + let mut d = s.doc().unwrap().clone(); + d.vote_history = vec![record(&[("peer 7", "Grace")])]; + std::sync::Arc::new(d) + }; + let r = ex(&mut s, "board.voteResults", json!({})); + assert_eq!( + r["sessions"][0]["results"][0]["voters"], + json!([{"voter": "peer 7", "name": "Grace", "votes": 1}, {"voter": "peer 9", "name": null, "votes": 1}]), + "a name kept, and one nobody knew" + ); +} diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index d7ccd6bee..ad4de5461 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -23,6 +23,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: طلبات السحب: {prs}، الإيداعات: {commits}، +{added} / −{deleted} سطر (Δ {delta})، +{bin_added} / −{bin_deleted} أصل ثنائي، {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "مجموعة من هذا الملف بأوضاعها ومتغيراتها: تجاوز قيمها لكل وضع وأضف متغيرات خاصة بها" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "يوجد ملف .figcraft بهذا الاسم. اختر اسمه الكامل لتأكيد الاستبدال." +"A later vote" = "تصويت أحدث" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "تُحاذى الطبقة المنفردة مع إطارها: حدد عدة طبقات أو طبقة داخل إطار" "A responsive product page: hero, features, figures, a quote and a footer" = "صفحة منتج متجاوبة: قسم رئيسي ومزايا وأرقام واقتباس وتذييل" "A spring plays until it settles: its mass, stiffness and damping set how long" = "تستمر حركة النابض حتى يستقر؛ وتحدد كتلته وصلابته وتخميده مدتها" @@ -115,6 +116,7 @@ status = "draft — needs native review" "Alphabetical" = "أبجدي" "Alt+drag" = "Alt+سحب" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "شاشة تحليلات شبكية: أرقام ومخططات مرسومة بمتجهات وجدول" +"An earlier vote" = "تصويت أقدم" "An invite link that opens FigCraft and joins" = "رابط دعوة يفتح FigCraft وينضم إلى الجلسة" "Anchor" = "مرساة" "Android" = "Android" @@ -241,6 +243,7 @@ status = "draft — needs native review" "Convert to frame" = "تحويل إلى إطار" "Convert to section" = "تحويل إلى قسم" "CSS custom properties…" = "خصائص CSS المخصصة…" +"Create from color" = "إنشاء من اللون" "Dart constants for Flutter…" = "ثوابت Dart لـ Flutter…" "Can edit" = "يمكنه التحرير" "Can't read this library: {e}" = "تعذرت قراءة هذه المكتبة: {e}" @@ -407,6 +410,7 @@ status = "draft — needs native review" "Delete page" = "حذف الصفحة" "Delete preset" = "حذف الإعداد المسبق" "Delete property" = "حذف الخاصية" +"Delete results" = "حذف النتائج" "Delete the style (layers keep their values)" = "حذف النمط (تحتفظ الطبقات بقيمها)" "Delete thread" = "حذف سلسلة التعليقات" "Delete variable" = "حذف المتغير" @@ -811,8 +815,10 @@ status = "draft — needs native review" "Main menu" = "القائمة الرئيسية" "Make a component" = "إنشاء مكوّن" "Make slot" = "إنشاء خانة محتوى" +"New collection" = "مجموعة جديدة" "Pasted design layers" = "طبقات تصميم ملصقة" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "لُصقت طبقات التصميم؛ لم تحمل الحافظة صور: {layers}" +"Remove these results from the file" = "احذف هذه النتائج من الملف" "Scroll wheel zoom" = "التكبير بعجلة التمرير" "Slot name" = "اسم خانة المحتوى" "Mar" = "مارس" @@ -951,6 +957,8 @@ status = "draft — needs native review" "Not read yet" = "لم تُقرأ بعد" "Check for updates" = "التحقق من التحديثات" "Not checked yet" = "لم يُتحقق منها بعد" +"Someone who left" = "شخص غادر" +"Take the results off the board; they stay in the file" = "أزل النتائج من اللوحة؛ تبقى في الملف" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "يستخدم هذا الملف مكتبات فريق على خوادم لم تستخدمها هنا. لا يُتصل بها حتى تتحقق منها." "Not shared" = "غير مشارك" "Note {n}" = "الملاحظة {n}" @@ -1822,6 +1830,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · على خادم" "{name} · published version {v}" = "{name} · الإصدار المنشور {v}" "{name} · version {v} on the server" = "{name} · الإصدار {v} على الخادم" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}، في جلسة مباشرة" "{name}, online, open only to you" = "{name}، على الإنترنت ومتاح لك وحدك" "{name}, {kind}, {w} by {h}, selected" = "{name}، {kind}، {w} × {h}، محدد" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 1f27a73d1..4f789900a 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -18,6 +18,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs}টি PR, {commits}টি কমিট, +{added} / −{deleted} লাইন (Δ {delta}), +{bin_added} / −{bin_deleted} বাইনারি অ্যাসেট, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "এই মোড এবং ভেরিয়েবল সহ এই ফাইলের একটি সংগ্রহ: প্রতি মোডে তাদের মান ওভাররাইড করুন এবং এর নিজস্ব ভেরিয়েবল যোগ করুন" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "এই নামের একটি .figcraft ফাইল ইতিমধ্যে বিদ্যমান। প্রতিস্থাপন নিশ্চিত করতে এর সম্পূর্ণ নাম বেছে নিন।" +"A later vote" = "পরের ভোট" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "একটি লেয়ার একা তার ফ্রেমের সাথে সারিবদ্ধ হয়: আরও লেয়ার নির্বাচন করুন, বা ফ্রেমে থাকা একটি লেয়ার নির্বাচন করুন" "A responsive product page: hero, features, figures, a quote and a footer" = "একটি রেসপনসিভ প্রোডাক্ট পেজ: হিরো, ফিচার, ফিগার, একটি উদ্ধৃতি এবং একটি ফুটার" "A spring plays until it settles: its mass, stiffness and damping set how long" = "একটি স্প্রিং স্থির হওয়া পর্যন্ত চলে: এর ভর, দৃঢ়তা (stiffness) এবং ড্যাম্পিং নির্ধারণ করে কতক্ষণ চলবে" @@ -110,6 +111,7 @@ status = "draft — needs native review" "Alphabetical" = "বর্ণানুক্রমিক" "Alt+drag" = "Alt+ড্র্যাগ" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "গ্রিডের উপর একটি অ্যানালিটিক্স স্ক্রিন: ফিগার, ভেক্টর থেকে আঁকা চার্ট এবং একটি টেবিল" +"An earlier vote" = "আগের ভোট" "An invite link that opens FigCraft and joins" = "একটি আমন্ত্রণ লিংক যা FigCraft খুলে যুক্ত হয়" "Anchor" = "অ্যাঙ্কর" "Android" = "অ্যান্ড্রয়েড" @@ -351,6 +353,7 @@ status = "draft — needs native review" "Create component" = "কম্পোনেন্ট তৈরি করুন" "Create component property" = "কম্পোনেন্ট প্রপার্টি তৈরি করুন" "Create components, styles or variables to publish them as a library." = "লাইব্রেরি হিসেবে প্রকাশ করতে কম্পোনেন্ট, স্টাইল বা ভেরিয়েবল তৈরি করুন।" +"Create from color" = "রং থেকে তৈরি করুন" "Create property…" = "প্রপার্টি তৈরি করুন..." "Create style" = "স্টাইল তৈরি করুন" "Create style from the selected text" = "নির্বাচিত টেক্সট থেকে স্টাইল তৈরি করুন" @@ -387,6 +390,7 @@ status = "draft — needs native review" "Delete page" = "পেজ ডিলিট করুন" "Delete preset" = "প্রিসেট ডিলিট করুন" "Delete property" = "প্রপার্টি ডিলিট করুন" +"Delete results" = "ফলাফল মুছুন" "Delete the style (layers keep their values)" = "স্টাইল ডিলিট করুন (লেয়ারগুলো তাদের মান ধরে রাখবে)" "Delete thread" = "থ্রেড ডিলিট করুন" "Delete variable" = "ভেরিয়েবল ডিলিট করুন" @@ -779,8 +783,10 @@ status = "draft — needs native review" "Main menu" = "প্রধান মেনু" "Make a component" = "একটি কম্পোনেন্ট তৈরি করুন" "Make slot" = "স্লট তৈরি করুন" +"New collection" = "নতুন সংগ্রহ" "Pasted design layers" = "পেস্ট করা ডিজাইন লেয়ার" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "ডিজাইন লেয়ার পেস্ট হয়েছে; ক্লিপবোর্ডে এগুলোর ছবি ছিল না: {layers}" +"Remove these results from the file" = "এই ফলাফল ফাইল থেকে মুছুন" "Scroll wheel zoom" = "স্ক্রল হুইল দিয়ে জুম" "Slot name" = "স্লটের নাম" "Mar" = "মার্চ" @@ -919,6 +925,8 @@ status = "draft — needs native review" "Not read yet" = "এখনো পড়া হয়নি" "Check for updates" = "আপডেট দেখুন" "Not checked yet" = "এখনো যাচাই করা হয়নি" +"Someone who left" = "কেউ একজন যিনি চলে গেছেন" +"Take the results off the board; they stay in the file" = "ফলাফল বোর্ড থেকে সরান; সেগুলো ফাইলে থাকবে" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "এই ফাইলটি এমন সার্ভারের টিম লাইব্রেরি ব্যবহার করে যা আপনি এখানে ব্যবহার করেননি। আপনি যাচাই না করা পর্যন্ত সেগুলোর সাথে যোগাযোগ করা হয় না।" "Not shared" = "শেয়ার করা হয়নি" "Note {n}" = "নোট {n}" @@ -1763,6 +1771,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · একটি সার্ভারে" "{name} · published version {v}" = "{name} · প্রকাশিত সংস্করণ {v}" "{name} · version {v} on the server" = "{name} · সার্ভারে সংস্করণ {v}" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, লাইভ সেশনে" "{name}, online, open only to you" = "{name}, অনলাইনে, শুধুমাত্র আপনার জন্য খোলা" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, নির্বাচিত" diff --git a/crates/ui-egui/locales/bs.toml b/crates/ui-egui/locales/bs.toml index 12f088d30..30d3979ec 100644 --- a/crates/ui-egui/locales/bs.toml +++ b/crates/ui-egui/locales/bs.toml @@ -243,6 +243,7 @@ status = "draft — needs native review" "Convert to frame" = "Pretvori u okvir" "Convert to section" = "Pretvori u sekciju" "CSS custom properties…" = "Prilagođena CSS svojstva…" +"Create from color" = "Napravi iz boje" "Dart constants for Flutter…" = "Dart konstante za Flutter…" "Can edit" = "Može uređivati" "Can't read this library: {e}" = "Nije moguće pročitati ovu biblioteku: {e}" @@ -823,6 +824,7 @@ status = "draft — needs native review" "Main menu" = "Glavni meni" "Make a component" = "Napravi komponentu" "Make slot" = "Napravi slot" +"New collection" = "Nova kolekcija" "Pasted design layers" = "Zalijepljeni slojevi dizajna" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Zalijepljeni slojevi dizajna; međuspremnik nije prenio slike za: {layers}" "Scroll wheel zoom" = "Zumiranje kotačićem miša" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 0e8ead999..493fcd5ff 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -14,6 +14,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commity: {commits}, +{added} / −{deleted} řádků (Δ {delta}), +{bin_added} / −{bin_deleted} binárních souborů, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekce tohoto souboru s těmito režimy a proměnnými: přepište jejich hodnoty pro jednotlivé režimy a přidejte vlastní proměnné" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Soubor .figcraft s tímto názvem už existuje. Nahrazení potvrdíte výběrem jeho úplného názvu." +"A later vote" = "Pozdější hlasování" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Samostatná vrstva se zarovná ke svému rámci: vyberte více vrstev nebo jednu v rámci" "A responsive product page: hero, features, figures, a quote and a footer" = "Responzivní produktová stránka: úvodní blok, funkce, čísla, citace a zápatí" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Pružina se pohybuje až do ustálení: délku určuje její hmotnost, tuhost a tlumení" @@ -104,6 +105,7 @@ status = "draft — needs native review" "Alphabetical" = "Abecedně" "Alt+drag" = "Alt+tažení" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Analytický přehled na mřížce: čísla, vektorové grafy a tabulka" +"An earlier vote" = "Dřívější hlasování" "An invite link that opens FigCraft and joins" = "Odkaz s pozvánkou, který otevře FigCraft a připojí k relaci" "Anchor" = "Kotva" "Android" = "Android" @@ -341,6 +343,7 @@ status = "draft — needs native review" "Create component" = "Vytvořit komponentu" "Create component property" = "Vytvořit vlastnost komponenty" "Create components, styles or variables to publish them as a library." = "Vytvořte komponenty, styly nebo proměnné a publikujte je jako knihovnu." +"Create from color" = "Vytvořit z barvy" "Create property…" = "Vytvořit vlastnost…" "Create style" = "Vytvořit styl" "Create style from the selected text" = "Vytvořit styl z vybraného textu" @@ -377,6 +380,7 @@ status = "draft — needs native review" "Delete page" = "Smazat stránku" "Delete preset" = "Smazat předvolbu" "Delete property" = "Smazat vlastnost" +"Delete results" = "Smazat výsledky" "Delete the style (layers keep their values)" = "Smazat styl (vrstvy si zachovají hodnoty)" "Delete thread" = "Smazat vlákno" "Delete variable" = "Smazat proměnnou" @@ -764,8 +768,10 @@ status = "draft — needs native review" "Main menu" = "Hlavní nabídka" "Make a component" = "Vytvořit komponentu" "Make slot" = "Vytvořit slot" +"New collection" = "Nová kolekce" "Pasted design layers" = "Vložené návrhové vrstvy" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Návrhové vrstvy vloženy; schránka neobsahovala obrázky těchto vrstev: {layers}" +"Remove these results from the file" = "Odebrat tyto výsledky ze souboru" "Scroll wheel zoom" = "Přibližování kolečkem myši" "Slot name" = "Název slotu" "Mar" = "března" @@ -901,6 +907,8 @@ status = "draft — needs native review" "Not read yet" = "Dosud nepřečteno" "Check for updates" = "Zkontrolovat aktualizace" "Not checked yet" = "Dosud nezkontrolováno" +"Someone who left" = "Někdo, kdo odešel" +"Take the results off the board; they stay in the file" = "Odebrat výsledky z tabule; zůstanou v souboru" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tento soubor používá týmové knihovny na serverech, které jste tu dosud nepoužili. Nekontaktují se, dokud je nezkontrolujete." "Not shared" = "Nesdíleno" "Note {n}" = "Poznámka {n}" @@ -1727,6 +1735,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · na serveru" "{name} · published version {v}" = "{name} · publikovaná verze {v}" "{name} · version {v} on the server" = "{name} · verze {v} na serveru" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, v živé relaci" "{name}, online, open only to you" = "{name}, online, přístupný pouze vám" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, vybráno" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index f44c72517..a9391c2f0 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -18,6 +18,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{bin_added} / −{bin_deleted} Binärdateien, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Eine Sammlung dieser Datei mit diesen Modi und Variablen: Überschreiben Sie ihre Werte je Modus und fügen Sie eigene Variablen hinzu" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Eine .figcraft-Datei mit diesem Namen existiert bereits. Wählen Sie ihren vollständigen Namen, um das Ersetzen zu bestätigen." +"A later vote" = "Eine spätere Abstimmung" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Eine einzelne Ebene richtet sich an ihrem Rahmen aus: Wählen Sie weitere Ebenen oder eine Ebene in einem Rahmen" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Eine Feder läuft, bis sie zur Ruhe kommt: Masse, Steifigkeit und Dämpfung bestimmen die Dauer" "A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Eine Tabelle hat höchstens {rows} Zeilen und {columns} Spalten: Der Rest wurde nicht eingefügt" @@ -104,6 +105,7 @@ status = "draft — needs native review" "Along the top" = "Entlang der Oberkante" "Alpha" = "Alpha" "Alt+drag" = "Alt+Ziehen" +"An earlier vote" = "Eine frühere Abstimmung" "An invite link that opens FigCraft and joins" = "Ein Einladungslink, der FigCraft öffnet und beitritt" "Anchor" = "Anker" "Android" = "Android" @@ -342,6 +344,7 @@ status = "draft — needs native review" "Create component" = "Komponente erstellen" "Create component property" = "Komponenteneigenschaft erstellen" "Create components, styles or variables to publish them as a library." = "Erstellen Sie Komponenten, Stile oder Variablen, um sie als Bibliothek zu veröffentlichen." +"Create from color" = "Aus Farbe erstellen" "Create property…" = "Eigenschaft erstellen…" "Create style" = "Stil erstellen" "Create style from the selected text" = "Stil aus dem ausgewählten Text erstellen" @@ -374,6 +377,7 @@ status = "draft — needs native review" "Delete page" = "Seite löschen" "Delete preset" = "Vorgabe löschen" "Delete property" = "Eigenschaft löschen" +"Delete results" = "Ergebnisse löschen" "Delete the style (layers keep their values)" = "Stil löschen (Ebenen behalten ihre Werte)" "Delete thread" = "Thread löschen" "Delete variable" = "Variable löschen" @@ -749,9 +753,11 @@ status = "draft — needs native review" "Main component deleted" = "Hauptkomponente gelöscht" "Main menu" = "Hauptmenü" "Make slot" = "Slot erstellen" +"New collection" = "Neue Sammlung" "Nobody shares a file there now." = "Dort teilt gerade niemand eine Datei." "Pasted design layers" = "Eingefügte Designebenen" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Designebenen eingefügt; die Zwischenablage enthielt die Bilder von {layers} nicht" +"Remove these results from the file" = "Diese Ergebnisse aus der Datei entfernen" "Scroll wheel zoom" = "Mit dem Mausrad zoomen" "Shared by your team on {server}" = "Von Ihrem Team geteilt auf {server}" "Slot name" = "Slot-Name" @@ -883,6 +889,8 @@ status = "draft — needs native review" "Not read yet" = "Noch nicht gelesen" "Check for updates" = "Nach Updates suchen" "Not checked yet" = "Noch nicht geprüft" +"Someone who left" = "Jemand, der gegangen ist" +"Take the results off the board; they stay in the file" = "Ergebnisse vom Board nehmen; sie bleiben in der Datei" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Diese Datei nutzt Teambibliotheken auf Servern, die Sie hier noch nicht verwendet haben. Diese Server werden erst kontaktiert, wenn Sie die Bibliotheken prüfen." "Not shared" = "Nicht geteilt" "Note {n}" = "Notiz {n}" @@ -1694,6 +1702,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · auf einem Server" "{name} · published version {v}" = "{name} · veröffentlichte Version {v}" "{name} · version {v} on the server" = "{name} · Version {v} auf dem Server" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, in einer Live-Sitzung" "{name}, online, open only to you" = "{name}, online, nur für Sie zugänglich" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, ausgewählt" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 215cef140..7a1a5572f 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -17,6 +17,7 @@ name = "English" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "A .figcraft file with this name already exists. Choose its full name to confirm replacement." +"A later vote" = "A later vote" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "A layer on its own aligns to its frame: select more layers, or one in a frame" "A responsive product page: hero, features, figures, a quote and a footer" = "A responsive product page: hero, features, figures, a quote and a footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long" @@ -111,6 +112,7 @@ name = "English" "Alphabetical" = "Alphabetical" "Alt+drag" = "Alt+drag" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "An analytics screen on a grid: figures, charts drawn from vectors and a table" +"An earlier vote" = "An earlier vote" "An invite link that opens FigCraft and joins" = "An invite link that opens FigCraft and joins" "Anchor" = "Anchor" "Android" = "Android" @@ -242,6 +244,7 @@ name = "English" "Copy browser link" = "Copy browser link" "Couldn't reach {server} for its team list." = "Couldn't reach {server} for its team list." "CSS custom properties…" = "CSS custom properties…" +"Create from color" = "Create from color" "Dart constants for Flutter…" = "Dart constants for Flutter…" "Calculated from other variables; it follows them" = "Calculated from other variables; it follows them" "Can edit" = "Can edit" @@ -412,6 +415,7 @@ name = "English" "Delete page" = "Delete page" "Delete preset" = "Delete preset" "Delete property" = "Delete property" +"Delete results" = "Delete results" "Delete the style (layers keep their values)" = "Delete the style (layers keep their values)" "Delete thread" = "Delete thread" "Delete variable" = "Delete variable" @@ -826,9 +830,11 @@ name = "English" "Main menu" = "Main menu" "Make a component" = "Make a component" "Make slot" = "Make slot" +"New collection" = "New collection" "Nobody shares a file there now." = "Nobody shares a file there now." "Pasted design layers" = "Pasted design layers" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Pasted design layers; the clipboard didn't carry the images of: {layers}" +"Remove these results from the file" = "Remove these results from the file" "Scroll wheel zoom" = "Scroll wheel zoom" "Shared by your team on {server}" = "Shared by your team on {server}" "Slot name" = "Slot name" @@ -1302,6 +1308,8 @@ name = "English" "Search files and templates" = "Search files and templates" "Search results" = "Search results" "Search fonts" = "Search fonts" +"Someone who left" = "Someone who left" +"Take the results off the board; they stay in the file" = "Take the results off the board; they stay in the file" "Use fonts installed on this computer…" = "Use fonts installed on this computer…" "Search layers" = "Search layers" "Search pages" = "Search pages" @@ -1863,6 +1871,7 @@ name = "English" "{name} · on a server" = "{name} · on a server" "{name} · published version {v}" = "{name} · published version {v}" "{name} · version {v} on the server" = "{name} · version {v} on the server" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, in a live session" "{name}, online, open only to you" = "{name}, online, open only to you" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} by {h}, selected" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 8227f9b08..17a02b7e1 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,6 +8,7 @@ name = "Español" status = "draft — needs native review" [ui] +"A later vote" = "Una votación posterior" "A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Una tabla admite como máximo {rows} filas y {columns} columnas: el resto no se pegó" "Add a connected shape" = "Añadir una forma conectada" "Add a minute" = "Añadir un minuto" @@ -19,6 +20,7 @@ status = "draft — needs native review" "Along the left" = "A lo largo del borde izquierdo" "Along the right" = "A lo largo del borde derecho" "Along the top" = "A lo largo del borde superior" +"An earlier vote" = "Una votación anterior" "Android Vector Drawable" = "Android Vector Drawable" "Best effort" = "Mejor aproximación" "Between the selected layers" = "Entre las capas seleccionadas" @@ -32,10 +34,16 @@ status = "draft — needs native review" "Connector" = "Conector" "Couldn't download the variables" = "No se pudieron descargar las variables" "Couldn't download {name}" = "No se pudo descargar {name}" +"Create from color" = "Crear a partir del color" +"New collection" = "Nueva colección" +"Delete results" = "Eliminar resultados" "Pasted design layers" = "Capas de diseño pegadas" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Capas de diseño pegadas; el portapapeles no traía las imágenes de: {layers}" "Relative to" = "Relativa a" +"Remove these results from the file" = "Quitar estos resultados del archivo" "Scroll wheel zoom" = "Zoom con la rueda del ratón" +"Someone who left" = "Alguien que se fue" +"Take the results off the board; they stay in the file" = "Quitar los resultados del tablero; se quedan en el archivo" "Trigger layer" = "Capa que la abre" "Whole screen" = "Pantalla completa" @@ -1810,6 +1818,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · en un servidor" "{name} · published version {v}" = "{name} · versión publicada {v}" "{name} · version {v} on the server" = "{name} · versión {v} en el servidor" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, en una sesión en vivo" "{name}, online, open only to you" = "{name}, en línea, abierto solo para ti" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} por {h}, seleccionado" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 2a857cb73..c95e8d8b7 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -19,6 +19,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR:t {prs}, muutokset {commits}, rivit +{added} / −{deleted} (Δ {delta}), binääriresurssit +{bin_added} / −{bin_deleted}, {first} – {last}" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Tämänniminen .figcraft-tiedosto on jo olemassa. Vahvista korvaaminen valitsemalla sen koko nimi." "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Tämän tiedoston kokoelma, joka sisältää nämä tilat ja muuttujat. Voit ohittaa niiden arvot tilakohtaisesti ja lisätä omia muuttujia." +"A later vote" = "Myöhempi äänestys" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Yksittäinen taso tasataan kehykseensä. Valitse useampia tasoja tai yksi kehyksen sisällä oleva taso." "A responsive product page: hero, features, figures, a quote and a footer" = "Mukautuva tuotesivu: pääosio, ominaisuudet, tunnusluvut, lainaus ja alatunniste" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Jousi liikkuu, kunnes se asettuu. Sen massa, jäykkyys ja vaimennus määräävät keston." @@ -113,6 +114,7 @@ status = "draft — needs native review" "Alphabetical" = "Aakkosjärjestys" "Alt+drag" = "Alt+drag" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Analytiikkanäkymä ruudukossa: tunnuslukuja, vektoreista piirrettyjä kaavioita ja taulukko" +"An earlier vote" = "Aiempi äänestys" "An invite link that opens FigCraft and joins" = "Kutsulinkki, joka avaa FigCraftin ja liittyy istuntoon" "Anchor" = "Ankkuri" "Android" = "Android" @@ -374,6 +376,7 @@ status = "draft — needs native review" "Create component" = "Luo komponentti" "Create component property" = "Luo komponentin ominaisuus" "Create components, styles or variables to publish them as a library." = "Luo komponentteja, tyylejä tai muuttujia, jotta voit julkaista ne kirjastona." +"Create from color" = "Luo väristä" "Create property…" = "Luo ominaisuus…" "Create style" = "Luo tyyli" "Create style from the selected text" = "Luo tyyli valitusta tekstistä" @@ -411,6 +414,7 @@ status = "draft — needs native review" "Delete page" = "Poista sivu" "Delete preset" = "Poista esiasetus" "Delete property" = "Poista ominaisuus" +"Delete results" = "Poista tulokset" "Delete the style (layers keep their values)" = "Poista tyyli (tasojen arvot säilyvät)" "Delete thread" = "Poista keskustelu" "Delete variable" = "Poista muuttuja" @@ -826,8 +830,10 @@ status = "draft — needs native review" "Make a component" = "Luo komponentti" "Make slot" = "Luo sisältöpaikka" "Nobody shares a file there now." = "Kukaan ei jaa siellä nyt tiedostoa." +"New collection" = "Uusi kokoelma" "Pasted design layers" = "Liitetyt suunnittelutasot" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Suunnittelutasot liitetty; leikepöydällä ei ollut näiden kuvia: {layers}" +"Remove these results from the file" = "Poista nämä tulokset tiedostosta" "Scroll wheel zoom" = "Zoomaus hiiren rullalla" "Shared by your team on {server}" = "Tiimisi jakama palvelimella {server}" "Slot name" = "Sisältöpaikan nimi" @@ -971,6 +977,8 @@ status = "draft — needs native review" "Check for updates…" = "Tarkista päivitykset…" "Checking for updates…" = "Tarkistetaan päivityksiä…" "Not checked yet" = "Ei vielä tarkistettu" +"Someone who left" = "Joku, joka lähti" +"Take the results off the board; they stay in the file" = "Poista tulokset taululta; ne jäävät tiedostoon" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tämä tiedosto käyttää tiimikirjastoja palvelimilla, joita et ole käyttänyt täällä. Niihin ei oteta yhteyttä ennen kuin tarkistat ne." "Not shared" = "Ei jaettu" "Note {n}" = "Huomautus {n}" @@ -1865,6 +1873,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · palvelimella" "{name} · published version {v}" = "{name} · julkaistu versio {v}" "{name} · version {v} on the server" = "{name} · versio {v} palvelimella" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, reaaliaikaisessa istunnossa" "{name}, online, open only to you" = "{name}, verkossa, vain sinun käytössäsi" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, valittu" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 1159eea60..bf10299df 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -18,6 +18,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{bin_added} / −{bin_deleted} ressources binaires, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Un fichier .figcraft portant ce nom existe déjà. Choisissez son nom complet pour confirmer le remplacement." +"A later vote" = "Un vote plus récent" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre" "A responsive product page: hero, features, figures, a quote and a footer" = "Une page produit responsive : héros, fonctionnalités, chiffres, une citation et un pied de page" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée" @@ -109,6 +110,7 @@ status = "draft — needs native review" "Alphabetical" = "Alphabétique" "Alt+drag" = "Alt+glisser" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Un écran d’analyse sur une grille : chiffres, graphiques tracés en vectoriel et un tableau" +"An earlier vote" = "Un vote plus ancien" "An invite link that opens FigCraft and joins" = "Un lien d’invitation qui ouvre FigCraft et rejoint la session" "Anchor" = "Ancre" "Android" = "Android" @@ -351,6 +353,7 @@ status = "draft — needs native review" "Create component" = "Créer un composant" "Create component property" = "Créer une propriété de composant" "Create components, styles or variables to publish them as a library." = "Créez des composants, des styles ou des variables pour les publier en tant que bibliothèque." +"Create from color" = "Créer à partir de la couleur" "Create property…" = "Créer une propriété…" "Create style" = "Créer un style" "Create style from the selected text" = "Créer un style à partir du texte sélectionné" @@ -387,6 +390,7 @@ status = "draft — needs native review" "Delete page" = "Supprimer la page" "Delete preset" = "Supprimer le préréglage" "Delete property" = "Supprimer la propriété" +"Delete results" = "Supprimer les résultats" "Delete the style (layers keep their values)" = "Supprimer le style (les calques conservent leurs valeurs)" "Delete thread" = "Supprimer le fil" "Delete variable" = "Supprimer la variable" @@ -781,8 +785,10 @@ status = "draft — needs native review" "Main menu" = "Menu principal" "Make a component" = "Créer un composant" "Make slot" = "Créer un emplacement" +"New collection" = "Nouvelle collection" "Pasted design layers" = "Calques de design collés" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Calques de design collés ; le presse-papiers ne contenait pas les images de : {layers}" +"Remove these results from the file" = "Retirer ces résultats du fichier" "Scroll wheel zoom" = "Zoom avec la molette" "Slot name" = "Nom de l'emplacement" "Mar" = "mars" @@ -919,6 +925,8 @@ status = "draft — needs native review" "Not read yet" = "Pas encore lu" "Check for updates" = "Rechercher des mises à jour" "Not checked yet" = "Pas encore vérifiée" +"Someone who left" = "Quelqu'un qui est parti" +"Take the results off the board; they stay in the file" = "Retirer les résultats du tableau ; ils restent dans le fichier" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Ce fichier utilise des bibliothèques d’équipe sur des serveurs que vous n’avez pas utilisés ici. Ils ne sont contactés que lorsque vous les vérifiez." "Not shared" = "Non partagé" "Note {n}" = "Note {n}" @@ -1770,6 +1778,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · sur un serveur" "{name} · published version {v}" = "{name} · version publiée {v}" "{name} · version {v} on the server" = "{name} · version {v} sur le serveur" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, dans une session en direct" "{name}, online, open only to you" = "{name}, en ligne, accessible à vous seul" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} sur {h}, sélectionné" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 20d7c83f5..fa7da6650 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -8,12 +8,20 @@ name = "Bahasa Indonesia" status = "draft — needs native review" [ui] +"A later vote" = "Pemungutan suara yang lebih baru" "A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Tabel memuat paling banyak {rows} baris dan {columns} kolom: sisanya tidak ditempel" "Add a connected shape" = "Tambahkan bentuk yang terhubung" +"Create from color" = "Buat dari warna" +"New collection" = "Koleksi baru" +"An earlier vote" = "Pemungutan suara yang lebih lama" +"Delete results" = "Hapus hasil" "Pasted design layers" = "Lapisan desain yang ditempel" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Lapisan desain ditempel; papan klip tidak membawa gambar dari: {layers}" "Relative to" = "Relatif terhadap" +"Remove these results from the file" = "Hapus hasil ini dari file" "Scroll wheel zoom" = "Zoom dengan roda gulir" +"Someone who left" = "Seseorang yang sudah keluar" +"Take the results off the board; they stay in the file" = "Hapus hasil dari papan; tetap tersimpan di file" "Trigger layer" = "Layer pemicu" "Whole screen" = "Seluruh layar" @@ -1731,6 +1739,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · di server" "{name} · published version {v}" = "{name} · versi terpublikasi {v}" "{name} · version {v} on the server" = "{name} · versi {v} di server" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, dalam sesi langsung" "{name}, online, open only to you" = "{name}, online, hanya terbuka untuk Anda" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} x {h}, dipilih" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index fe471dbed..f8f25723c 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} righe (Δ {delta}), +{bin_added} / −{bin_deleted} asset binari, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Una collezione di questo file con queste modalità e variabili: sovrascrivi i loro valori per modalità e aggiungi variabili proprie" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Esiste già un file .figcraft con questo nome. Scegli il nome completo per confermare la sostituzione." +"A later vote" = "Una votazione successiva" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un livello da solo si allinea al proprio frame: seleziona più livelli, oppure uno dentro un frame" "A responsive product page: hero, features, figures, a quote and a footer" = "Una pagina prodotto responsive: hero, funzionalità, figure, una citazione e un footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Una molla si muove finché non si assesta: massa, rigidità e smorzamento ne determinano la durata" @@ -100,6 +101,7 @@ status = "draft — needs native review" "Alphabetical" = "Alfabetico" "Alt+drag" = "Alt+trascina" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Una schermata di analisi su una griglia: cifre, grafici disegnati con vettori e una tabella" +"An earlier vote" = "Una votazione precedente" "An invite link that opens FigCraft and joins" = "Un link di invito che apre FigCraft e si unisce" "Anchor" = "Ancora" "Android" = "Android" @@ -335,6 +337,7 @@ status = "draft — needs native review" "Create component" = "Crea componente" "Create component property" = "Crea proprietà del componente" "Create components, styles or variables to publish them as a library." = "Crea componenti, stili o variabili per pubblicarli come libreria." +"Create from color" = "Crea dal colore" "Create property…" = "Crea proprietà…" "Create style" = "Crea stile" "Create style from the selected text" = "Crea stile dal testo selezionato" @@ -371,6 +374,7 @@ status = "draft — needs native review" "Delete page" = "Elimina pagina" "Delete preset" = "Elimina preset" "Delete property" = "Elimina proprietà" +"Delete results" = "Elimina risultati" "Delete the style (layers keep their values)" = "Elimina lo stile (i livelli mantengono i propri valori)" "Delete thread" = "Elimina discussione" "Delete variable" = "Elimina variabile" @@ -755,8 +759,10 @@ status = "draft — needs native review" "Main menu" = "Menu principale" "Make a component" = "Crea un componente" "Make slot" = "Crea slot" +"New collection" = "Nuova collezione" "Pasted design layers" = "Livelli di design incollati" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Livelli di design incollati; gli appunti non contenevano le immagini di: {layers}" +"Remove these results from the file" = "Rimuovi questi risultati dal file" "Scroll wheel zoom" = "Zoom con la rotella del mouse" "Slot name" = "Nome dello slot" "Mar" = "mar" @@ -890,6 +896,8 @@ status = "draft — needs native review" "Not read yet" = "Non ancora letto" "Check for updates" = "Verifica aggiornamenti" "Not checked yet" = "Non ancora verificata" +"Someone who left" = "Qualcuno che è uscito" +"Take the results off the board; they stay in the file" = "Togli i risultati dalla lavagna; restano nel file" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Questo file usa librerie del team su server che non hai usato qui. Non vengono contattati finché non le verifichi." "Not shared" = "Non condiviso" "Note {n}" = "Nota {n}" @@ -1707,6 +1715,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · su un server" "{name} · published version {v}" = "{name} · versione {v} pubblicata" "{name} · version {v} on the server" = "{name} · versione {v} sul server" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, in una sessione live" "{name}, online, open only to you" = "{name}, online, aperto solo a te" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} per {h}, selezionato" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index b051fa240..3902da32d 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -20,6 +20,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:PR {prs} 件、コミット {commits} 件、+{added} / −{deleted} 行(Δ {delta})、バイナリアセット +{bin_added} / −{bin_deleted}、{first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "このファイルのコレクションとそのモード・変数:モードごとに値を上書きし、独自の変数を追加できる" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "この名前の .figcraft ファイルは既に存在します。置き換えるには、拡張子まで含む名前を選択。" +"A later vote" = "後の投票" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "単独のレイヤーはフレームに整列します:レイヤーを追加で選択するか、フレーム内のレイヤーを選択" "A responsive product page: hero, features, figures, a quote and a footer" = "レスポンシブな製品ページ:ヒーロー、機能、数値、引用、フッター" "A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる" @@ -110,6 +111,7 @@ status = "draft — needs native review" "Alphabetical" = "名前順" "Alt+drag" = "Alt+ドラッグ" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "グリッドに沿った分析画面:数値、ベクターで描いたグラフ、表" +"An earlier vote" = "前の投票" "An invite link that opens FigCraft and joins" = "FigCraft を開いて参加する招待リンク" "Anchor" = "アンカー" "Android" = "Android" @@ -349,6 +351,7 @@ status = "draft — needs native review" "Create component" = "コンポーネントを作成" "Create component property" = "コンポーネントプロパティを作成" "Create components, styles or variables to publish them as a library." = "コンポーネント、スタイル、変数を作成すると、ライブラリとして公開できる。" +"Create from color" = "色から作成" "Create property…" = "プロパティを作成…" "Create style" = "スタイルを作成" "Create style from the selected text" = "選択したテキストからスタイルを作成" @@ -385,6 +388,7 @@ status = "draft — needs native review" "Delete page" = "ページを削除" "Delete preset" = "プリセットを削除" "Delete property" = "プロパティを削除" +"Delete results" = "結果を削除" "Delete the style (layers keep their values)" = "スタイルを削除(レイヤーの値は保持)" "Delete thread" = "スレッドを削除" "Delete variable" = "変数を削除" @@ -775,8 +779,10 @@ status = "draft — needs native review" "Main menu" = "メインメニュー" "Make a component" = "コンポーネントを作成" "Make slot" = "スロットにする" +"New collection" = "新しいコレクション" "Pasted design layers" = "貼り付けたデザインレイヤー" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "デザインレイヤーを貼り付けました。クリップボードに次の画像がありませんでした: {layers}" +"Remove these results from the file" = "この結果をファイルから削除します" "Scroll wheel zoom" = "スクロールホイールでズーム" "Slot name" = "スロット名" "Mar" = "3月" @@ -912,6 +918,8 @@ status = "draft — needs native review" "Not read yet" = "未読" "Check for updates" = "アップデートを確認" "Not checked yet" = "未確認" +"Someone who left" = "退出した人" +"Take the results off the board; they stay in the file" = "ボードから結果を外します(ファイルには残ります)" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "このファイルは、ここで使ったことのないサーバー上のチームライブラリを使う。確認するまでそれらのサーバーには接続しない。" "Not shared" = "共有していない" "Note {n}" = "メモ {n}" @@ -1751,6 +1759,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · サーバー上" "{name} · published version {v}" = "{name} · 公開バージョン {v}" "{name} · version {v} on the server" = "{name} · サーバー上のバージョン {v}" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}、ライブセッション中" "{name}, online, open only to you" = "{name}、オンライン、開けるのはあなただけ" "{name}, {kind}, {w} by {h}, selected" = "{name}、{kind}、{w} × {h}、選択中" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 5f5411698..834d4c512 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -20,6 +20,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, კომიტი: {commits}, +{added} / −{deleted} სტრიქონი (Δ {delta}), +{bin_added} / −{bin_deleted} ბინარული რესურსი, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "ამ ფაილის კოლექცია ამ რეჟიმებითა და ცვლადებით: შეცვალეთ მათი მნიშვნელობები თითოეული რეჟიმისთვის და დაამატეთ საკუთარი ცვლადები" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "ამ სახელით .figcraft ფაილი უკვე არსებობს. ჩანაცვლების დასადასტურებლად აირჩიეთ მისი სრული სახელი." +"A later vote" = "მოგვიანებითი კენჭისყრა" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "ცალკე შრე თავის ჩარჩოსთან სწორდება: მონიშნეთ მეტი შრე ან ჩარჩოში მყოფი შრე" "A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი ყველა ძირითადი ბლოკით" "A spring plays until it settles: its mass, stiffness and damping set how long" = "ზამბარა მოძრაობს, სანამ არ დამშვიდდება: მისი მასა, სიხისტე და მილევა განსაზღვრავს ხანგრძლივობას" @@ -112,6 +113,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Alphabetical" = "ანბანის მიხედვით" "Alt+drag" = "Alt+გადათრევა" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკა ბადეზე: ციფრები, დიაგრამები და ცხრილი" +"An earlier vote" = "ადრინდელი კენჭისყრა" "An invite link that opens FigCraft and joins" = "მოსაწვევი ბმული, რომელიც ხსნის FigCraft-ს და უერთდება სესიას" "Anchor" = "საყრდენი" "Android" = "Android" @@ -356,6 +358,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Create component" = "კომპონენტის შექმნა" "Create component property" = "კომპონენტის თვისების შექმნა" "Create components, styles or variables to publish them as a library." = "შექმენით კომპონენტები, სტილები ან ცვლადები, რომ ბიბლიოთეკად გამოაქვეყნოთ." +"Create from color" = "ფერიდან შექმნა" "Create property…" = "თვისების შექმნა…" "Create style" = "სტილის შექმნა" "Create style from the selected text" = "სტილის შექმნა მონიშნული ტექსტიდან" @@ -392,6 +395,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Delete page" = "გვერდის წაშლა" "Delete preset" = "პრესეტის წაშლა" "Delete property" = "თვისების წაშლა" +"Delete results" = "შედეგების წაშლა" "Delete the style (layers keep their values)" = "სტილის წაშლა (შრეები ინარჩუნებს მნიშვნელობებს)" "Delete thread" = "კომენტარისა და პასუხების წაშლა" "Delete variable" = "ცვლადის წაშლა" @@ -788,8 +792,10 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Main menu" = "მთავარი მენიუ" "Make a component" = "შექმენით კომპონენტი" "Make slot" = "სლოტად გადაქცევა" +"New collection" = "ახალი კოლექცია" "Pasted design layers" = "ჩასმული დიზაინის ფენები" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "დიზაინის ფენები ჩაისვა; ბუფერში არ იყო შემდეგის სურათები: {layers}" +"Remove these results from the file" = "ამ შედეგების ფაილიდან წაშლა" "Scroll wheel zoom" = "მასშტაბირება გადახვევის ბორბლით" "Slot name" = "სლოტის სახელი" "Mar" = "მარ" @@ -928,6 +934,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Not read yet" = "ჯერ არ არის წაკითხული" "Check for updates" = "განახლებების შემოწმება" "Not checked yet" = "ჯერ არ არის შემოწმებული" +"Someone who left" = "ვინც წავიდა" +"Take the results off the board; they stay in the file" = "შედეგების დაფიდან მოხსნა; ფაილში დარჩება" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "ეს ფაილი იყენებს გუნდის ბიბლიოთეკებს სერვერებზე, რომლებიც აქ არ გამოგიყენებიათ. მათ არ დავუკავშირდებით, სანამ არ შეამოწმებთ." "Not shared" = "არ არის გაზიარებული" "Note {n}" = "შენიშვნა {n}" @@ -1790,6 +1798,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "{name} · on a server" = "{name} · სერვერზე" "{name} · published version {v}" = "{name} · გამოქვეყნებული ვერსია {v}" "{name} · version {v} on the server" = "{name} · ვერსია {v} სერვერზე" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, ცოცხალ სესიაში" "{name}, online, open only to you" = "{name}, ონლაინ, ღიაა მხოლოდ თქვენთვის" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, მონიშნულია" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 583a6b4de..202290ab2 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -19,6 +19,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR {prs}개, 커밋 {commits}개, +{added} / −{deleted}줄 (Δ {delta}), 바이너리 에셋 +{bin_added} / −{bin_deleted}, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "이 파일의 컬렉션과 그 모드 및 변수: 모드별로 값을 재정의하고 자체 변수를 추가할 수 있음" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "이 이름의 .figcraft 파일이 이미 있습니다. 바꾸려면 확장자까지 포함한 전체 이름을 선택하세요." +"A later vote" = "이후 투표" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "레이어 하나는 프레임에 맞춰 정렬됩니다: 레이어를 더 선택하거나 프레임 안의 레이어를 선택하세요" "A spring plays until it settles: its mass, stiffness and damping set how long" = "스프링은 멈출 때까지 재생됨. 길이는 질량, 강성, 감쇠로 결정됨" "A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "표는 최대 {rows}행 {columns}열입니다. 나머지는 붙여넣지 않았습니다" @@ -104,6 +105,7 @@ status = "draft — needs native review" "Along the top" = "위쪽 가장자리를 따라" "Alpha" = "알파" "Alt+drag" = "Alt+드래그" +"An earlier vote" = "이전 투표" "An invite link that opens FigCraft and joins" = "FigCraft를 열고 참여하는 초대 링크" "Anchor" = "앵커" "Android" = "Android" @@ -338,6 +340,7 @@ status = "draft — needs native review" "Create component" = "컴포넌트 만들기" "Create component property" = "컴포넌트 속성 만들기" "Create components, styles or variables to publish them as a library." = "컴포넌트, 스타일 또는 변수를 만들어 라이브러리로 게시하세요." +"Create from color" = "색상으로 만들기" "Create property…" = "속성 만들기…" "Create style" = "스타일 만들기" "Create style from the selected text" = "선택한 텍스트로 스타일 만들기" @@ -370,6 +373,7 @@ status = "draft — needs native review" "Delete page" = "페이지 삭제" "Delete preset" = "프리셋 삭제" "Delete property" = "속성 삭제" +"Delete results" = "결과 삭제" "Delete the style (layers keep their values)" = "스타일 삭제(레이어는 값을 유지함)" "Delete thread" = "스레드 삭제" "Delete variable" = "변수 삭제" @@ -739,8 +743,10 @@ status = "draft — needs native review" "Main component deleted" = "메인 컴포넌트가 삭제됨" "Main menu" = "메인 메뉴" "Make slot" = "슬롯으로 만들기" +"New collection" = "새 컬렉션" "Pasted design layers" = "붙여넣은 디자인 레이어" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "디자인 레이어를 붙여넣었습니다. 클립보드에 다음 이미지가 없었습니다: {layers}" +"Remove these results from the file" = "이 결과를 파일에서 삭제합니다" "Scroll wheel zoom" = "스크롤 휠로 확대/축소" "Slot name" = "슬롯 이름" "Margin" = "여백" @@ -871,6 +877,8 @@ status = "draft — needs native review" "Not read yet" = "아직 읽지 않음" "Check for updates" = "업데이트 확인" "Not checked yet" = "아직 확인 안 함" +"Someone who left" = "나간 사람" +"Take the results off the board; they stay in the file" = "보드에서 결과를 내립니다. 파일에는 남습니다" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "이 파일은 여기서 사용한 적 없는 서버의 팀 라이브러리를 사용합니다. 확인하기 전까지는 해당 서버에 연결하지 않습니다." "Not shared" = "공유 안 함" "Note {n}" = "메모 {n}" @@ -1676,6 +1684,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · 서버에 있음" "{name} · published version {v}" = "{name} · 게시된 버전 {v}" "{name} · version {v} on the server" = "{name} · 서버의 버전 {v}" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, 라이브 세션 중" "{name}, online, open only to you" = "{name}, 온라인, 나만 열 수 있음" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, 선택됨" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index fc8fab856..58fda9aee 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -19,6 +19,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} regels (Δ {delta}), +{bin_added} / −{bin_deleted} binaire assets, {first} – {last}" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Er bestaat al een .figcraft-bestand met deze naam. Kies de volledige naam om de vervanging te bevestigen." "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Een verzameling van dit bestand met deze modi en variabelen: overschrijf hun waarden per modus en voeg er eigen variabelen toe" +"A later vote" = "Een latere stemming" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Een losse laag wordt uitgelijnd op zijn frame: selecteer meer lagen, of één in een frame" "A responsive product page: hero, features, figures, a quote and a footer" = "Een responsive productpagina: hero, features, afbeeldingen, een citaat en een footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Een veer speelt tot hij tot rust komt: zijn massa, stijfheid en demping bepalen hoe lang" @@ -111,6 +112,7 @@ status = "draft — needs native review" "Alphabetical" = "Alfabetisch" "Alt+drag" = "Alt+drag" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Een analysescherm op een raster: cijfers, diagrammen getekend met vectoren en een tabel" +"An earlier vote" = "Een eerdere stemming" "An invite link that opens FigCraft and joins" = "Een uitnodigingslink die FigCraft opent en waarmee je deelneemt" "Anchor" = "Anker" "Android" = "Android" @@ -353,6 +355,7 @@ status = "draft — needs native review" "Create component" = "Component maken" "Create component property" = "Componenteigenschap maken" "Create components, styles or variables to publish them as a library." = "Maak componenten, stijlen of variabelen om ze als bibliotheek te publiceren." +"Create from color" = "Maken van kleur" "Create property…" = "Eigenschap maken…" "Create style" = "Stijl maken" "Create style from the selected text" = "Stijl maken van de geselecteerde tekst" @@ -389,6 +392,7 @@ status = "draft — needs native review" "Delete page" = "Pagina verwijderen" "Delete preset" = "Preset verwijderen" "Delete property" = "Eigenschap verwijderen" +"Delete results" = "Resultaten verwijderen" "Delete the style (layers keep their values)" = "Verwijdert de stijl (lagen behouden hun waarden)" "Delete thread" = "Gesprek verwijderen" "Delete variable" = "Variabele verwijderen" @@ -781,8 +785,10 @@ status = "draft — needs native review" "Main menu" = "Hoofdmenu" "Make a component" = "Maak een component" "Make slot" = "Slot maken" +"New collection" = "Nieuwe verzameling" "Pasted design layers" = "Geplakte ontwerplagen" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Ontwerplagen geplakt; het klembord bevatte de afbeeldingen van {layers} niet" +"Remove these results from the file" = "Deze resultaten uit het bestand verwijderen" "Scroll wheel zoom" = "Zoomen met scrollwiel" "Slot name" = "Slotnaam" "Mar" = "mrt." @@ -921,6 +927,8 @@ status = "draft — needs native review" "Not read yet" = "Nog niet gelezen" "Check for updates" = "Controleren op updates" "Not checked yet" = "Nog niet gecontroleerd" +"Someone who left" = "Iemand die vertrokken is" +"Take the results off the board; they stay in the file" = "Resultaten van het bord halen; ze blijven in het bestand" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Dit bestand gebruikt teambibliotheken op servers die je hier nog niet hebt gebruikt. Er wordt pas contact mee gemaakt als je ze controleert." "Not shared" = "Niet gedeeld" "Note {n}" = "Notitie {n}" @@ -1766,6 +1774,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · op een server" "{name} · published version {v}" = "{name} · gepubliceerde versie {v}" "{name} · version {v} on the server" = "{name} · versie {v} op de server" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, in een live sessie" "{name}, online, open only to you" = "{name}, online, alleen voor jou toegankelijk" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} bij {h}, geselecteerd" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index b1883d1fa..b27e587f4 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -17,6 +17,7 @@ name = "Polski" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commitów: {commits}, +{added} / −{deleted} wierszy (Δ {delta}), +{bin_added} / −{bin_deleted} zasobów binarnych, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekcja tego pliku z tymi trybami i zmiennymi: nadpisuj ich wartości dla poszczególnych trybów i dodawaj własne zmienne" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Plik .figcraft o tej nazwie już istnieje. Wybierz pełną nazwę, aby potwierdzić zastąpienie." +"A later vote" = "Późniejsze głosowanie" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Pojedyncza warstwa wyrównuje się do swojej ramki: zaznacz więcej warstw lub warstwę w ramce" "A responsive product page: hero, features, figures, a quote and a footer" = "Responsywna strona produktu: sekcja hero, funkcje, statystyki, cytat i stopka" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Animacja sprężynowa trwa do ustabilizowania: jej masa, sztywność i tłumienie określają czas" @@ -109,6 +110,7 @@ name = "Polski" "Alphabetical" = "Alfabetycznie" "Alt+drag" = "Alt+przeciągnięcie" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Ekran analityczny na siatce: wykresy wektorowe, liczby i tabela" +"An earlier vote" = "Wcześniejsze głosowanie" "An invite link that opens FigCraft and joins" = "Link z zaproszeniem otwierający FigCraft i dołączający do sesji" "Anchor" = "Punkt zaczepienia" "Android" = "Android" @@ -352,6 +354,7 @@ name = "Polski" "Create component" = "Utwórz komponent" "Create component property" = "Utwórz właściwość komponentu" "Create components, styles or variables to publish them as a library." = "Twórz komponenty, style lub zmienne, aby opublikować je jako bibliotekę." +"Create from color" = "Utwórz z koloru" "Create property…" = "Utwórz właściwość…" "Create style" = "Utwórz styl" "Create style from the selected text" = "Utwórz styl z zaznaczonego tekstu" @@ -388,6 +391,7 @@ name = "Polski" "Delete page" = "Usuń stronę" "Delete preset" = "Usuń ustawienie" "Delete property" = "Usuń właściwość" +"Delete results" = "Usuń wyniki" "Delete the style (layers keep their values)" = "Usuń styl (warstwy zachowają swoje wartości)" "Delete thread" = "Usuń wątek" "Delete variable" = "Usuń zmienną" @@ -783,8 +787,10 @@ name = "Polski" "Main menu" = "Menu główne" "Make a component" = "Utwórz komponent" "Make slot" = "Utwórz gniazdo (slot)" +"New collection" = "Nowa kolekcja" "Pasted design layers" = "Wklejone warstwy projektu" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Wklejono warstwy projektu; schowek nie zawierał obrazów warstw: {layers}" +"Remove these results from the file" = "Usuń te wyniki z pliku" "Scroll wheel zoom" = "Powiększanie kółkiem myszy" "Slot name" = "Nazwa gniazda" "Mar" = "Mar" @@ -923,6 +929,8 @@ name = "Polski" "Not read yet" = "Jeszcze nieprzeczytane" "Check for updates" = "Sprawdź aktualizacje" "Not checked yet" = "Jeszcze nie sprawdzono" +"Someone who left" = "Ktoś, kto wyszedł" +"Take the results off the board; they stay in the file" = "Zdejmij wyniki z tablicy; zostają w pliku" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Ten plik korzysta z bibliotek zespołu na serwerach, których tu jeszcze nie używano. Nie są kontaktowane, dopóki ich nie sprawdzisz." "Not shared" = "Nieudostępnione" "Note {n}" = "Notatka {n}" @@ -1779,6 +1787,7 @@ name = "Polski" "{name} · on a server" = "{name} · na serwerze" "{name} · published version {v}" = "{name} · opublikowana wersja {v}" "{name} · version {v} on the server" = "{name} · wersja {v} na serwerze" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, w sesji na żywo" "{name}, online, open only to you" = "{name}, online, otwarty tylko dla Ciebie" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, zaznaczono" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 1b8cbe78d..6c8907e78 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{bin_added} / −{bin_deleted} arquivos binários, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Uma coleção deste arquivo com estes modos e variáveis: substitua os valores por modo e adicione variáveis próprias" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Já existe um arquivo .figcraft com este nome. Escolha o nome completo dele para confirmar a substituição." +"A later vote" = "Uma votação posterior" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Uma camada sozinha se alinha ao seu quadro: selecione mais camadas ou uma dentro de um quadro" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Uma mola se move até se estabilizar: sua massa, rigidez e amortecimento definem a duração" "A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Uma tabela tem no máximo {rows} linhas e {columns} colunas: o resto não foi colado" @@ -102,6 +103,7 @@ status = "draft — needs native review" "Along the top" = "Ao longo da borda superior" "Alpha" = "Alfa" "Alt+drag" = "Alt+arrastar" +"An earlier vote" = "Uma votação anterior" "An invite link that opens FigCraft and joins" = "Um link de convite que abre o FigCraft e entra na sessão" "Anchor" = "Âncora" "Android" = "Android" @@ -337,6 +339,7 @@ status = "draft — needs native review" "Create component" = "Criar componente" "Create component property" = "Criar propriedade de componente" "Create components, styles or variables to publish them as a library." = "Crie componentes, estilos ou variáveis para publicá-los como biblioteca." +"Create from color" = "Criar a partir da cor" "Create property…" = "Criar propriedade…" "Create style" = "Criar estilo" "Create style from the selected text" = "Criar estilo a partir do texto selecionado" @@ -369,6 +372,7 @@ status = "draft — needs native review" "Delete page" = "Excluir página" "Delete preset" = "Excluir predefinição" "Delete property" = "Excluir propriedade" +"Delete results" = "Excluir resultados" "Delete the style (layers keep their values)" = "Excluir o estilo (as camadas mantêm seus valores)" "Delete thread" = "Excluir conversa" "Delete variable" = "Excluir variável" @@ -738,8 +742,10 @@ status = "draft — needs native review" "Main component deleted" = "Componente principal excluído" "Main menu" = "Menu principal" "Make slot" = "Transformar em slot" +"New collection" = "Nova coleção" "Pasted design layers" = "Camadas de design coladas" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Camadas de design coladas; a área de transferência não trouxe as imagens de: {layers}" +"Remove these results from the file" = "Remover estes resultados do arquivo" "Scroll wheel zoom" = "Zoom com a roda do mouse" "Slot name" = "Nome do slot" "Margin" = "Margem" @@ -870,6 +876,8 @@ status = "draft — needs native review" "Not read yet" = "Ainda não lido" "Check for updates" = "Verificar atualizações" "Not checked yet" = "Ainda não verificada" +"Someone who left" = "Alguém que saiu" +"Take the results off the board; they stay in the file" = "Tirar os resultados do quadro; eles ficam no arquivo" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Este arquivo usa bibliotecas de equipe em servidores que você não usou aqui. Eles só são contatados quando você os verifica." "Not shared" = "Não compartilhado" "Note {n}" = "Nota {n}" @@ -1674,6 +1682,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · em um servidor" "{name} · published version {v}" = "{name} · versão publicada {v}" "{name} · version {v} on the server" = "{name} · versão {v} no servidor" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, em uma sessão ao vivo" "{name}, online, open only to you" = "{name}, online, aberto somente para você" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} por {h}, selecionado" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 5ebee5897..d23752e0b 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -17,6 +17,7 @@ name = "Русский" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, коммитов: {commits}, +{added} / −{deleted} строк (Δ {delta}), +{bin_added} / −{bin_deleted} бинарных ресурсов, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Коллекция этого файла с этими режимами и переменными: переопределяйте значения для каждого режима и добавляйте собственные переменные" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Файл .figcraft с таким именем уже существует. Чтобы подтвердить замену, выберите его полное имя." +"A later vote" = "Более позднее голосование" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Отдельный слой выравнивается по своему фрейму: выделите больше слоёв или слой внутри фрейма." "A responsive product page: hero, features, figures, a quote and a footer" = "Адаптивная страница продукта: главный блок, возможности, показатели, цитата и подвал" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Пружинная анимация длится до затухания: её время задают масса, жёсткость и демпфирование" @@ -109,6 +110,7 @@ name = "Русский" "Alphabetical" = "По алфавиту" "Alt+drag" = "Alt+перетаскивание" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Экран аналитики на сетке: показатели, векторные диаграммы и таблица" +"An earlier vote" = "Более раннее голосование" "An invite link that opens FigCraft and joins" = "Ссылка приглашения, которая открывает FigCraft и подключает к сессии" "Anchor" = "Привязка" "Android" = "Android" @@ -352,6 +354,7 @@ name = "Русский" "Create component" = "Создать компонент" "Create component property" = "Создать свойство компонента" "Create components, styles or variables to publish them as a library." = "Создайте компоненты, стили или переменные, чтобы опубликовать их как библиотеку." +"Create from color" = "Создать из цвета" "Create property…" = "Создать свойство…" "Create style" = "Создать стиль" "Create style from the selected text" = "Создать стиль из выделенного текста" @@ -388,6 +391,7 @@ name = "Русский" "Delete page" = "Удалить страницу" "Delete preset" = "Удалить пресет" "Delete property" = "Удалить свойство" +"Delete results" = "Удалить результаты" "Delete the style (layers keep their values)" = "Удалить стиль (значения слоёв сохранятся)" "Delete thread" = "Удалить обсуждение" "Delete variable" = "Удалить переменную" @@ -783,8 +787,10 @@ name = "Русский" "Main menu" = "Главное меню" "Make a component" = "Создать компонент" "Make slot" = "Сделать слотом" +"New collection" = "Новая коллекция" "Pasted design layers" = "Вставленные слои дизайна" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Слои дизайна вставлены; в буфере обмена не было изображений слоёв: {layers}" +"Remove these results from the file" = "Удалить эти результаты из файла" "Scroll wheel zoom" = "Масштабирование колесом мыши" "Slot name" = "Имя слота" "Mar" = "мар." @@ -923,6 +929,8 @@ name = "Русский" "Not read yet" = "Ещё не прочитано" "Check for updates" = "Проверить обновления" "Not checked yet" = "Ещё не проверено" +"Someone who left" = "Тот, кто вышел" +"Take the results off the board; they stay in the file" = "Убрать результаты с доски; они останутся в файле" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Этот файл использует командные библиотеки на серверах, которые вы здесь не использовали. К ним не обращаются, пока вы их не проверите." "Not shared" = "Не опубликовано" "Note {n}" = "Заметка {n}" @@ -1779,6 +1787,7 @@ name = "Русский" "{name} · on a server" = "{name} · на сервере" "{name} · published version {v}" = "{name} · опубликована версия {v}" "{name} · version {v} on the server" = "{name} · версия {v} на сервере" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, в совместной сессии" "{name}, online, open only to you" = "{name}, онлайн, доступен только вам" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} на {h}, выделено" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 40eccf530..6c7306acd 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -17,6 +17,7 @@ name = "Svenska" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{bin_added} / −{bin_deleted} binära resurser, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "En samling i den här filen med dessa lägen och variabler: ange overrides för deras värden per läge och lägg till egna variabler" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Det finns redan en .figcraft-fil med det här namnet. Välj dess fullständiga namn för att bekräfta att den ersätts." +"A later vote" = "En senare omröstning" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Ett ensamt lager justeras mot sin frame: markera fler lager, eller ett lager i en frame" "A responsive product page: hero, features, figures, a quote and a footer" = "En responsiv produktsida: toppsektion, funktioner, figurer, ett citat och en sidfot" "A spring plays until it settles: its mass, stiffness and damping set how long" = "En fjäder spelas tills den stannar: dess massa, styvhet och dämpning avgör hur länge" @@ -111,6 +112,7 @@ name = "Svenska" "Alphabetical" = "I bokstavsordning" "Alt+drag" = "Alt+dra" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "En analysskärm på ett rutnät: figurer, diagram ritade med vektorer och en tabell" +"An earlier vote" = "En tidigare omröstning" "An invite link that opens FigCraft and joins" = "En inbjudningslänk som öppnar FigCraft och ansluter" "Anchor" = "Ankare" "Android" = "Android" @@ -239,6 +241,7 @@ name = "Svenska" "Convert to frame" = "Konvertera till frame" "Convert to section" = "Konvertera till avsnitt" "CSS custom properties…" = "Anpassade CSS-egenskaper…" +"Create from color" = "Skapa från färg" "Dart constants for Flutter…" = "Dart-konstanter för Flutter…" "Can edit" = "Kan redigera" "Can't read this library: {e}" = "Det går inte att läsa det här biblioteket: {e}" @@ -408,6 +411,7 @@ name = "Svenska" "Delete page" = "Radera sida" "Delete preset" = "Radera förinställning" "Delete property" = "Radera egenskap" +"Delete results" = "Ta bort resultat" "Delete the style (layers keep their values)" = "Radera stilen (lagren behåller sina värden)" "Delete thread" = "Radera tråd" "Delete variable" = "Radera variabel" @@ -818,8 +822,10 @@ name = "Svenska" "Main menu" = "Huvudmeny" "Make a component" = "Skapa en komponent" "Make slot" = "Gör till plats" +"New collection" = "Ny samling" "Pasted design layers" = "Inklistrade designlager" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Designlager inklistrade; urklippet innehöll inte bilderna för: {layers}" +"Remove these results from the file" = "Ta bort de här resultaten från filen" "Scroll wheel zoom" = "Zooma med scrollhjulet" "Slot name" = "Platsens namn" "Mar" = "Mar" @@ -1292,6 +1298,8 @@ name = "Svenska" "Search files and templates" = "Sök efter filer och mallar" "Search results" = "Sökresultat" "Search fonts" = "Sök typsnitt" +"Someone who left" = "Någon som har lämnat" +"Take the results off the board; they stay in the file" = "Ta bort resultaten från tavlan; de finns kvar i filen" "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" @@ -1849,6 +1857,7 @@ name = "Svenska" "{name} · on a server" = "{name} · på en server" "{name} · published version {v}" = "{name} · publicerad version {v}" "{name} · version {v} on the server" = "{name} · version {v} på servern" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, i en livesession" "{name}, online, open only to you" = "{name}, online, öppen bara för dig" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} gånger {h}, markerad" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index b2e821b4a..9887aa3f2 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -22,6 +22,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} คอมมิต, +{added} / −{deleted} บรรทัด (Δ {delta}), แอสเซ็ตไบนารี +{bin_added} / −{bin_deleted}, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "คอลเลกชันของไฟล์นี้พร้อมโหมดและตัวแปรเหล่านี้: เขียนทับค่าตามโหมดและเพิ่มตัวแปรของตัวเองได้" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "มีไฟล์ .figcraft ชื่อนี้อยู่แล้ว เลือกชื่อเต็มของไฟล์เพื่อยืนยันการแทนที่" +"A later vote" = "การโหวตครั้งถัดมา" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "เลเยอร์เดี่ยวจะจัดแนวตามเฟรมของมัน: เลือกเลเยอร์เพิ่ม หรือเลือกเลเยอร์ในเฟรม" "A responsive product page: hero, features, figures, a quote and a footer" = "หน้าสินค้าแบบรีสปอนซีฟ: ฮีโร่ ฟีเจอร์ ตัวเลข คำพูดอ้างอิง และฟุตเตอร์" "A spring plays until it settles: its mass, stiffness and damping set how long" = "สปริงเล่นจนนิ่ง: มวล ความแข็ง และการหน่วงกำหนดระยะเวลา" @@ -110,6 +111,7 @@ status = "draft — needs native review" "Alphabetical" = "ตามตัวอักษร" "Alt+drag" = "Alt+ลาก" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "หน้าจอวิเคราะห์ข้อมูลบนกริด: ตัวเลข แผนภูมิที่วาดจากเวกเตอร์ และตาราง" +"An earlier vote" = "การโหวตครั้งก่อน" "An invite link that opens FigCraft and joins" = "ลิงก์เชิญที่เปิด FigCraft และเข้าร่วม" "Anchor" = "จุดยึด" "Android" = "Android" @@ -350,6 +352,7 @@ status = "draft — needs native review" "Create component" = "สร้างคอมโพเนนต์" "Create component property" = "สร้างพร็อพเพอร์ตีของคอมโพเนนต์" "Create components, styles or variables to publish them as a library." = "สร้างคอมโพเนนต์ สไตล์ หรือตัวแปรเพื่อเผยแพร่เป็นไลบรารี" +"Create from color" = "สร้างจากสี" "Create property…" = "สร้างพร็อพเพอร์ตี…" "Create style" = "สร้างสไตล์" "Create style from the selected text" = "สร้างสไตล์จากข้อความที่เลือก" @@ -386,6 +389,7 @@ status = "draft — needs native review" "Delete page" = "ลบหน้า" "Delete preset" = "ลบพรีเซ็ต" "Delete property" = "ลบพร็อพเพอร์ตี" +"Delete results" = "ลบผลลัพธ์" "Delete the style (layers keep their values)" = "ลบสไตล์ (เลเยอร์ยังคงค่าเดิม)" "Delete thread" = "ลบเธรด" "Delete variable" = "ลบตัวแปร" @@ -777,8 +781,10 @@ status = "draft — needs native review" "Main menu" = "เมนูหลัก" "Make a component" = "สร้างคอมโพเนนต์" "Make slot" = "สร้างสล็อต" +"New collection" = "คอลเลกชันใหม่" "Pasted design layers" = "เลเยอร์ดีไซน์ที่วาง" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "วางเลเยอร์ดีไซน์แล้ว คลิปบอร์ดไม่มีภาพของ: {layers}" +"Remove these results from the file" = "ลบผลลัพธ์นี้ออกจากไฟล์" "Scroll wheel zoom" = "ซูมด้วยล้อเลื่อนเมาส์" "Slot name" = "ชื่อสล็อต" "Mar" = "มี.ค." @@ -915,6 +921,8 @@ status = "draft — needs native review" "Not read yet" = "ยังไม่ได้อ่าน" "Check for updates" = "ตรวจหาอัปเดต" "Not checked yet" = "ยังไม่ได้ตรวจ" +"Someone who left" = "คนที่ออกไปแล้ว" +"Take the results off the board; they stay in the file" = "นำผลลัพธ์ออกจากบอร์ด โดยยังเก็บไว้ในไฟล์" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "ไฟล์นี้ใช้ไลบรารีทีมบนเซิร์ฟเวอร์ที่คุณยังไม่เคยใช้ที่นี่ จะไม่มีการติดต่อเซิร์ฟเวอร์เหล่านั้นจนกว่าคุณจะตรวจ" "Not shared" = "ไม่ได้แชร์" "Note {n}" = "โน้ต {n}" @@ -1757,6 +1765,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · อยู่บนเซิร์ฟเวอร์" "{name} · published version {v}" = "{name} · เผยแพร่เวอร์ชัน {v}" "{name} · version {v} on the server" = "{name} · เวอร์ชัน {v} บนเซิร์ฟเวอร์" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name} อยู่ในเซสชันสด" "{name}, online, open only to you" = "{name} ออนไลน์ เปิดได้เฉพาะคุณ" "{name}, {kind}, {w} by {h}, selected" = "{name} {kind} {w} คูณ {h} เลือกอยู่" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 7a3a6c33e..0f565011b 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -19,6 +19,7 @@ name = "Türkçe" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} satır (Δ {delta}), +{bin_added} / −{bin_deleted} ikili varlık, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Bu dosyanın bu modları ve değişkenleri içeren bir koleksiyonu: değerlerini mod başına geçersiz kılın ve kendi değişkenlerinizi ekleyin" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Bu adda bir .figcraft dosyası zaten var. Değiştirmeyi onaylamak için tam adını seçin." +"A later vote" = "Daha sonraki oylama" "A responsive product page: hero, features, figures, a quote and a footer" = "Duyarlı bir ürün sayfası: giriş bölümü, özellikler, rakamlar, bir alıntı ve bir alt bilgi" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Yay, durulana kadar oynar: kütlesi, sertliği ve sönümü ne kadar süreceğini belirler" "A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Bir tablo en fazla {rows} satır ve {columns} sütun alır: gerisi yapıştırılmadı" @@ -108,6 +109,7 @@ name = "Türkçe" "Alphabetical" = "Alfabetik" "Alt+drag" = "Alt+sürükle" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Izgara üzerinde bir analiz ekranı: rakamlar, vektörlerden çizilmiş grafikler ve bir tablo" +"An earlier vote" = "Daha önceki oylama" "An invite link that opens FigCraft and joins" = "FigCraft'ı açıp katılan bir davet bağlantısı" "Anchor" = "Bağlantı noktası" "Android" = "Android" @@ -345,6 +347,7 @@ name = "Türkçe" "Create component" = "Bileşen oluştur" "Create component property" = "Bileşen özelliği oluştur" "Create components, styles or variables to publish them as a library." = "Kitaplık olarak yayımlamak için bileşen, stil veya değişken oluşturun." +"Create from color" = "Renkten oluştur" "Create property…" = "Özellik oluştur…" "Create style" = "Stil oluştur" "Create style from the selected text" = "Seçili metinden stil oluştur" @@ -381,6 +384,7 @@ name = "Türkçe" "Delete page" = "Sayfayı sil" "Delete preset" = "Ön ayarı sil" "Delete property" = "Özelliği sil" +"Delete results" = "Sonuçları sil" "Delete the style (layers keep their values)" = "Stili sil (katmanlar değerlerini korur)" "Delete thread" = "Konuyu sil" "Delete variable" = "Değişkeni sil" @@ -768,8 +772,10 @@ name = "Türkçe" "Main menu" = "Ana menü" "Make a component" = "Bileşen yap" "Make slot" = "Yuva yap" +"New collection" = "Yeni koleksiyon" "Pasted design layers" = "Yapıştırılan tasarım katmanları" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Tasarım katmanları yapıştırıldı; pano şunların görsellerini taşımıyordu: {layers}" +"Remove these results from the file" = "Bu sonuçları dosyadan kaldır" "Scroll wheel zoom" = "Kaydırma tekerleğiyle yakınlaştırma" "Slot name" = "Yuva adı" "Mar" = "Mar" @@ -905,6 +911,8 @@ name = "Türkçe" "Not read yet" = "Henüz okunmadı" "Check for updates" = "Güncellemeleri denetle" "Not checked yet" = "Henüz denetlenmedi" +"Someone who left" = "Ayrılan biri" +"Take the results off the board; they stay in the file" = "Sonuçları panodan kaldır; dosyada kalır" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Bu dosya, burada kullanmadığınız sunuculardaki ekip kitaplıklarını kullanıyor. Siz denetleyene kadar bu sunuculara bağlanılmaz." "Not shared" = "Paylaşılmadı" "Note {n}" = "Not {n}" @@ -1731,6 +1739,7 @@ name = "Türkçe" "{name} · on a server" = "{name} · bir sunucuda" "{name} · published version {v}" = "{name} · yayımlanan sürüm {v}" "{name} · version {v} on the server" = "{name} · sunucuda sürüm {v}" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, canlı oturumda" "{name}, online, open only to you" = "{name}, çevrimiçi, yalnızca size açık" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, seçili" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 1f2055c44..55ee7a563 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -18,6 +18,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} dòng (Δ {delta}), +{bin_added} / −{bin_deleted} tài nguyên nhị phân, {first} – {last}" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Đã tồn tại tệp .figcraft có tên này. Hãy chọn tên đầy đủ của tệp để xác nhận thay thế." "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Một bộ sưu tập của tệp này với các chế độ và biến sau: ghi đè giá trị theo từng chế độ và thêm các biến riêng" +"A later vote" = "Lần bình chọn sau" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Một lớp riêng lẻ sẽ căn chỉnh theo khung của nó: hãy chọn thêm lớp, hoặc chọn một lớp bên trong khung" "A responsive product page: hero, features, figures, a quote and a footer" = "Trang sản phẩm đáp ứng: phần mở đầu, tính năng, số liệu, trích dẫn và chân trang" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Chuyển động lò xo chạy cho đến khi ổn định: khối lượng, độ cứng và độ giảm chấn quyết định thời lượng" @@ -101,6 +102,7 @@ status = "draft — needs native review" "Alphabetical" = "Theo bảng chữ cái" "Alt+drag" = "Alt+kéo" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Màn hình phân tích trên lưới: số liệu, biểu đồ vẽ bằng vector và bảng dữ liệu" +"An earlier vote" = "Lần bình chọn trước" "An invite link that opens FigCraft and joins" = "Liên kết mời giúp mở FigCraft và tham gia ngay" "Anchor" = "Điểm neo" "Android" = "Android" @@ -336,6 +338,7 @@ status = "draft — needs native review" "Create component" = "Tạo thành phần" "Create component property" = "Tạo thuộc tính thành phần" "Create components, styles or variables to publish them as a library." = "Hãy tạo các thành phần, kiểu hoặc biến để xuất bản thành thư viện." +"Create from color" = "Tạo từ màu" "Create property…" = "Tạo thuộc tính…" "Create style" = "Tạo kiểu" "Create style from the selected text" = "Tạo kiểu từ văn bản đã chọn" @@ -372,6 +375,7 @@ status = "draft — needs native review" "Delete page" = "Xóa trang" "Delete preset" = "Xóa thiết lập sẵn" "Delete property" = "Xóa thuộc tính" +"Delete results" = "Xóa kết quả" "Delete the style (layers keep their values)" = "Xóa kiểu (các lớp vẫn giữ nguyên giá trị)" "Delete thread" = "Xóa luồng bình luận" "Delete variable" = "Xóa biến" @@ -756,8 +760,10 @@ 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" +"New collection" = "Bộ sưu tập mới" "Pasted design layers" = "Các lớp thiết kế đã dán" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Đã dán các lớp thiết kế; bộ nhớ tạm không có hình ảnh của: {layers}" +"Remove these results from the file" = "Xóa kết quả này khỏi tệp" "Scroll wheel zoom" = "Thu phóng bằng con lăn chuột" "Slot name" = "Tên ô chứa" "Mar" = "Thg 3" @@ -891,6 +897,8 @@ status = "draft — needs native review" "Not read yet" = "Chưa đọc" "Check for updates" = "Kiểm tra cập nhật" "Not checked yet" = "Chưa kiểm tra" +"Someone who left" = "Người đã rời đi" +"Take the results off the board; they stay in the file" = "Gỡ kết quả khỏi bảng; vẫn giữ trong tệp" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tệp này dùng thư viện nhóm trên các máy chủ bạn chưa dùng ở đây. Các máy chủ đó không được liên hệ cho đến khi bạn kiểm tra." "Not shared" = "Chưa chia sẻ" "Note {n}" = "Ghi chú {n}" @@ -1708,6 +1716,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · trên máy chủ" "{name} · published version {v}" = "{name} · đã xuất bản phiên bản {v}" "{name} · version {v} on the server" = "{name} · phiên bản {v} trên máy chủ" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name}, trong phiên trực tiếp" "{name}, online, open only to you" = "{name}, trực tuyến, chỉ mình bạn mở được" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, đã chọn" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a2969668c..65ba26b7c 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -20,6 +20,7 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:{prs} 个 PR,{commits} 次提交,+{added} / −{deleted} 行(Δ {delta}),二进制资源 +{bin_added} / −{bin_deleted},{first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "本文件中带有这些模式和变量的集合:可按模式覆盖其值,并添加自己的变量" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "已存在同名的 .figcraft 文件。请选择其完整名称以确认替换。" +"A later vote" = "较新的投票" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "单个图层与其所在画框对齐:请再选择其他图层,或选择画框内的图层" "A spring plays until it settles: its mass, stiffness and damping set how long" = "弹簧动画播放至静止,时长由质量、刚度和阻尼决定" "A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "表格最多 {rows} 行 {columns} 列:其余内容未粘贴" @@ -105,6 +106,7 @@ status = "draft — needs native review" "Along the top" = "沿顶边" "Alpha" = "Alpha" "Alt+drag" = "Alt+拖动" +"An earlier vote" = "较早的投票" "An invite link that opens FigCraft and joins" = "可打开 FigCraft 并加入的邀请链接" "Anchor" = "锚点" "Android" = "Android" @@ -342,6 +344,7 @@ status = "draft — needs native review" "Create component" = "创建组件" "Create component property" = "创建组件属性" "Create components, styles or variables to publish them as a library." = "创建组件、样式或变量,即可将其发布为库。" +"Create from color" = "从颜色创建" "Create property…" = "创建属性…" "Create style" = "创建样式" "Create style from the selected text" = "从所选文本创建样式" @@ -374,6 +377,7 @@ status = "draft — needs native review" "Delete page" = "删除页面" "Delete preset" = "删除预设" "Delete property" = "删除属性" +"Delete results" = "删除结果" "Delete the style (layers keep their values)" = "删除样式(图层保留其值)" "Delete thread" = "删除讨论" "Delete variable" = "删除变量" @@ -744,8 +748,10 @@ status = "draft — needs native review" "Main component deleted" = "主组件已删除" "Main menu" = "主菜单" "Make slot" = "设为插槽" +"New collection" = "新建集合" "Pasted design layers" = "粘贴的设计图层" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "已粘贴设计图层;剪贴板未包含以下图层的图像:{layers}" +"Remove these results from the file" = "从文件中删除这些结果" "Scroll wheel zoom" = "滚轮缩放" "Slot name" = "插槽名称" "Margin" = "边距" @@ -876,6 +882,8 @@ status = "draft — needs native review" "Not read yet" = "尚未读取" "Check for updates" = "检查更新" "Not checked yet" = "尚未检查" +"Someone who left" = "已离开的人" +"Take the results off the board; they stay in the file" = "从白板上移除结果;结果仍保留在文件中" "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "此文件使用了你在此处未用过的服务器上的团队库。在你检查之前,不会连接这些服务器。" "Not shared" = "未分享" "Note {n}" = "备注 {n}" @@ -1684,6 +1692,7 @@ status = "draft — needs native review" "{name} · on a server" = "{name} · 在服务器上" "{name} · published version {v}" = "{name} · 已发布版本 {v}" "{name} · version {v} on the server" = "{name} · 服务器上的版本 {v}" +"{name} ×{n}" = "{name} ×{n}" "{name}, in a live session" = "{name},正在实时会话中" "{name}, online, open only to you" = "{name},在线,只有你能打开" "{name}, {kind}, {w} by {h}, selected" = "{name},{kind},{w} × {h},已选中" diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index dc34885a9..5d7fcfd30 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -126,6 +126,7 @@ impl FigApp { } "dialog.preferences" => self.ui.preferences = true, "dialog.missingFonts" => missing_fonts::request(self), + "dialog.createFromColor" => crate::sampled_color::request(self, &r.data), "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, "palette.actions" => menus::toggle_palette(&mut self.ui), "panel.design" if self.session.doc().is_ok_and(figcraft_engine::board::is_board) => self.ui.board_panel = !self.ui.board_panel, diff --git a/crates/ui-egui/src/canvas/board_facilitate.rs b/crates/ui-egui/src/canvas/board_facilitate.rs index 2c5c66ab5..ee4f4d46e 100644 --- a/crates/ui-egui/src/canvas/board_facilitate.rs +++ b/crates/ui-egui/src/canvas/board_facilitate.rs @@ -4,7 +4,7 @@ use std::time::Duration; use egui::{Align2, Color32, CornerRadius, Rect, RichText, Stroke, Ui, pos2, vec2}; -use figcraft_doc::{BoardTimer, NodeId}; +use figcraft_doc::BoardTimer; use figcraft_engine::cmd::facilitate::{self, BADGE_PX}; use serde_json::{Value, json}; @@ -20,22 +20,22 @@ const PRESETS: [u32; 5] = [1, 3, 5, 10, 15]; /// What a vote can be on, as `board.vote {on}` takes it. const SCOPES: [(&str, &str); 3] = [("all", tr_noop("All objects")), ("stickies", tr_noop("Sticky notes")), ("selection", tr_noop("Selection"))]; -/// What the pill shows of the vote: none, running (your votes left) or ended (the results). +/// What the pill shows of the vote: none running (with Results when any are kept or on the +/// board), or running (your votes left). enum VoteView { - Off, + Off { results: bool }, Running { left: u32 }, - Ended { results: Vec<(usize, NodeId, String, u32)> }, } fn vote_view(app: &FigApp) -> VoteView { - let Ok(doc) = app.session.doc() else { return VoteView::Off }; - let Some(vote) = &doc.vote else { return VoteView::Off }; - if vote.ended { - let results = facilitate::ranked(doc, vote).into_iter().map(|(rank, id, n)| (rank, id, facilitate::object_name(doc, id), n)).collect(); - return VoteView::Ended { results }; + let Ok(doc) = app.session.doc() else { return VoteView::Off { results: false } }; + match &doc.vote { + Some(vote) if !vote.ended => { + let used: u32 = facilitate::mine(doc, &facilitate::voter(&app.session, &Value::Null)).iter().map(|m| m.1).sum(); + VoteView::Running { left: vote.per_person.saturating_sub(used) } + } + ended => VoteView::Off { results: ended.is_some() || !doc.vote_history.is_empty() }, } - let used: u32 = facilitate::mine(doc, &facilitate::voter(&app.session, &Value::Null)).iter().map(|m| m.1).sum(); - VoteView::Running { left: vote.per_person.saturating_sub(used) } } /// The pill at the top right of a board's canvas: the timer, then the vote. @@ -152,11 +152,20 @@ fn timer_popup(app: &mut FigApp, anchor: &egui::Response) { fn vote_part(app: &mut FigApp, ui: &mut Ui, vote: VoteView, editable: bool, t: &Tokens) { match vote { - VoteView::Off => { + VoteView::Off { results } => { let resp = icons::button(ui, Icon::Vote, CELL, false, tr("Voting session")); if editable { vote_popup(app, &resp); } + // Earlier votes' results stay in the file until deleted. + if results { + let resp = widgets::button(ui, tr("Results"), false, true); + if resp.clicked() { + // Opening shows the latest vote's results. + ui.data_mut(|d| d.remove::(egui::Id::unique("board_vote_session"))); + } + results_popup(app, &resp, editable); + } } VoteView::Running { left } => { let (r, resp) = ui.allocate_exact_size(vec2(20.0, CELL), egui::Sense::hover()); @@ -169,12 +178,6 @@ fn vote_part(app: &mut FigApp, ui: &mut Ui, vote: VoteView, editable: bool, t: & app.run("board.vote", json!({"action": "end"})); } } - VoteView::Ended { results } => { - let (r, _) = ui.allocate_exact_size(vec2(20.0, CELL), egui::Sense::hover()); - icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), Icon::Vote, t.accent_text); - let resp = widgets::button(ui, tr("Results"), false, true); - results_popup(app, &resp, &results, editable); - } } } @@ -213,26 +216,106 @@ fn vote_popup(app: &mut FigApp, anchor: &egui::Response) { }); } -/// The ended vote's results, most votes first: a click on a row selects that object. -fn results_popup(app: &mut FigApp, anchor: &egui::Response, results: &[(usize, NodeId, String, u32)], editable: bool) { - crate::right::menu_popup(anchor).width(260.0).show(|ui| { +/// Ended votes' results, newest first with arrows to earlier ones: each object, most votes first, +/// with who voted for it (a click on a row selects the object). Clear takes the results on the +/// board off it; Delete removes the results shown from the file. +fn results_popup(app: &mut FigApp, anchor: &egui::Response, editable: bool) { + // It stays open while you step through votes, pick objects or delete results. + crate::right::menu_popup(anchor).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + // Built only while it shows. + let sessions = facilitate::sessions(&app.session).unwrap_or_default(); + let pick_id = egui::Id::unique("board_vote_session"); + let pick = ui.data(|d| d.get_temp::(pick_id)).unwrap_or(0).min(sessions.len().saturating_sub(1)); + let secondary = Tokens::get(ui.ctx()).text_secondary; ui.add_space(2.0); - ui.label(RichText::new(tr("Results")).font(semibold(12.0))); - if results.is_empty() { - widgets::label(ui, tr("No votes yet"), Tokens::get(ui.ctx()).text_secondary); + if let Some(p) = results_header(ui, pick, sessions.len(), secondary) { + ui.data_mut(|d| d.insert_temp(pick_id, p)); } - for (rank, id, name, n) in results { - let label = format!("{rank}. {}", widgets::elide(ui, name, 160.0)); - if crate::right::menu_item(ui, &label, &trn("{n} votes", *n as usize, &[])) { - app.run("select.set", json!({"ids": [id]})); - } + if sessions.get(pick).is_none_or(|s| s.rows.is_empty()) { + widgets::label(ui, tr("No votes yet"), secondary); + } + let Some(session) = sessions.get(pick) else { return }; + for row in &session.rows { + result_row(app, ui, row, secondary); } if editable { - ui.add_space(4.0); - if widgets::button(ui, tr("Clear results"), false, true).clicked() { - app.run("board.vote", json!({"action": "clear"})); - ui.close(); + results_footer(app, ui, session); + } + }); +} + +/// The popover's title and, with several votes, the "‹ n of m ›" stepper. Returns the vote an +/// arrow stepped to. +fn results_header(ui: &mut Ui, pick: usize, count: usize, secondary: Color32) -> Option { + let mut step = None; + ui.horizontal(|ui| { + ui.label(RichText::new(tr("Results")).font(semibold(12.0))); + if count > 1 { + ui.add_space(8.0); + // Newest first: "1 of 3" is the last vote, the right arrow goes further back. Both + // arrows always show (one dimmed at an end), so the count stays put. + if ui.add_enabled_ui(pick > 0, |ui| widgets::icon_button(ui, Icon::ChevronLeft, tr("A later vote"))).inner { + step = pick.checked_sub(1); } + let at = trf("{n} of {total}", &[("n", &(pick + 1)), ("total", &count)]); + ui.label(RichText::new(at).font(regular(11.0)).color(secondary)); + if ui.add_enabled_ui(pick + 1 < count, |ui| widgets::icon_button(ui, Icon::ChevronRight, tr("An earlier vote"))).inner { + step = Some(pick + 1).filter(|p| *p < count); + } + } + }); + step +} + +/// One object in the results (a click selects it), with who voted for it under it. +fn result_row(app: &mut FigApp, ui: &mut Ui, row: &facilitate::ResultRow, secondary: Color32) { + let label = format!("{}. {}", row.rank, widgets::elide(ui, &row.name, 160.0)); + if crate::right::menu_item(ui, &label, &trn("{n} votes", row.votes as usize, &[])) { + app.run("select.set", json!({"ids": [row.id]})); + } + let full = voters_line(row); + let shown = widgets::elide(ui, &full, 236.0); + // Close under its row, apart from the next one. + ui.add_space(-8.0); + let resp = ui.horizontal(|ui| { + ui.add_space(8.0); + widgets::label(ui, &shown, secondary); + }); + ui.add_space(6.0); + if shown != full { + resp.response.on_hover_text(full); + } +} + +/// "Ada, Grace ×2": a person with several votes on the object says how many. +fn voters_line(row: &facilitate::ResultRow) -> String { + let who: Vec = row + .voters + .iter() + .map(|(_, name, n)| { + let name = name.clone().unwrap_or_else(|| tr("Someone who left").to_string()); + if *n > 1 { trf("{name} ×{n}", &[("name", &name), ("n", &n.to_string())]) } else { name } + }) + .collect(); + who.join(", ") +} + +/// Clear (while the results are on the board) and Delete (for kept results). +fn results_footer(app: &mut FigApp, ui: &mut Ui, session: &facilitate::VoteSession) { + ui.add_space(4.0); + ui.horizontal(|ui| { + if session.on_board + && widgets::button(ui, tr("Clear results"), false, true) + .on_hover_text(tr("Take the results off the board; they stay in the file")) + .clicked() + { + app.run("board.vote", json!({"action": "clear"})); + ui.close(); + } + if let Some(index) = session.index + && widgets::button(ui, tr("Delete results"), false, true).on_hover_text(tr("Remove these results from the file")).clicked() + { + app.run("board.vote", json!({"action": "delete", "index": index})); } }); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f2697f583..fd5a2a4e9 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -48,8 +48,8 @@ pub struct ControlReply { /// Which side has finished a control request. /// /// The transport and the UI race to settle one request: while it is `Open`, either may cancel it -/// (the transport's timeout) or commit it (the UI's final reply). Once settled, neither side may -/// change it. +/// (the transport's timeout) or commit it (the UI's final reply) or, for a request the UI answers +/// at once, start it (`Running`). Once settled or started, the transport can't cancel it. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum RequestState { #[default] @@ -58,6 +58,9 @@ pub enum RequestState { Cancelled, /// The UI wrote the file (if any) and queued the reply before releasing the gate. Replied, + /// The UI started a request it answers at once (an edit, an inspection); the transport can no + /// longer cancel it, so a timeout now means "still running, it will take effect" (#1673). + Running, } /// The gate the transport's timeout and the UI's final reply share for one request. @@ -102,6 +105,17 @@ impl RequestGate { *state } + /// Start a request the UI answers at once, unless the transport already gave up on it: then + /// it must not run at all. `false` when it was cancelled (or already settled). + pub fn start(&self) -> bool { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if *state != RequestState::Open { + return false; + } + *state = RequestState::Running; + true + } + /// Run the final commit (file write and reply enqueue) only while the request is still open, /// then mark it replied before releasing the gate. `None` means the transport cancelled it or /// it was already committed; the closure must not have run then. @@ -131,6 +145,13 @@ impl ControlRequest { pub fn is_cancelled(&self) -> bool { self.state.is_cancelled() } + + /// Mark the request started just before the UI runs it; `false` when the transport timed out + /// first, and then it must not run. `ui.input` is acknowledged by a later frame, so it stays + /// open (cancellable) like a screenshot. + fn begin(&self) -> bool { + self.method == "ui.input" || self.state.start() + } } /// One screenshot's completion slot. @@ -519,6 +540,9 @@ pub fn background(app: &mut FigApp, ctx: &Context) { let _ = r.reply.send(ControlReply { id: r.id, result: Err(error) }); continue; } + if !r.begin() { + continue; + } // Outside a frame, so outside the frame's guard: within the window's swap, a panic still // ends in recovery copies and leaves every window its own state. let res = crate::windows::as_window(app, target, |app| { @@ -719,6 +743,9 @@ fn route_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now admit(app, r, target, now); return; } + if !r.begin() { + return; + } // Every control-channel gesture is fenced from the user's system clipboard (the flag is // restored so a batch already in a frame's raw input keeps its own provenance). let before = app.control_synthetic; diff --git a/crates/ui-egui/src/dash_pattern_tests.rs b/crates/ui-egui/src/dash_pattern_tests.rs new file mode 100644 index 000000000..4244148fc --- /dev/null +++ b/crates/ui-egui/src/dash_pattern_tests.rs @@ -0,0 +1,66 @@ +//! The stroke settings' Dash and Gap fields edit the first pair of a dash pattern and keep the +//! rest of it (#1681): an imported `[4, 2, 1, 2]` used to collapse to `[dash, gap]` on the first edit. + +use egui::{Event, Id, Key, Modifiers}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// A selected rectangle with a stroke dashed `dashes`, its advanced stroke settings shown. +fn scene(dashes: serde_json::Value) -> (Ui, NodeId) { + let mut s = Session::new(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()["id"].as_u64().unwrap()); + s.execute("node.set", &json!({"ids": [r], "props": {"strokes": ["#000000"], "strokeWeight": 2, "dashes": dashes}})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.data_mut(|d| d.insert_temp(Id::unique("stroke_adv"), true)); + u.frame(vec![]); + (u, r) +} + +fn dashes(u: &Ui, r: NodeId) -> Vec { + u.app.session.doc().unwrap().node(r).unwrap().stroke.dashes.clone() +} + +/// Type `text` into the number field `id` and press Enter. +fn type_into(u: &mut Ui, id: &str, text: &str) { + let at = u.ctx.read_response(Id::unique(id).with("field")).unwrap_or_else(|| panic!("{id} shows")).rect.center(); + u.button(at, true); + u.button(at, false); + u.frame(vec![]); + u.frame(vec![Event::Text(text.into())]); + u.frame(vec![ + Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); + u.frame(vec![]); +} + +#[test] +fn editing_the_dash_or_gap_keeps_the_rest_of_the_pattern() { + let (mut u, r) = scene(json!([4, 2, 1, 2])); + type_into(&mut u, "stroke_dash", "6"); + assert_eq!(dashes(&u, r), [6.0, 2.0, 1.0, 2.0]); + type_into(&mut u, "stroke_gap", "3"); + assert_eq!(dashes(&u, r), [6.0, 3.0, 1.0, 2.0]); +} + +#[test] +fn a_single_value_pattern_is_the_dash_and_the_gap() { + let (mut u, r) = scene(json!([5])); + type_into(&mut u, "stroke_gap", "2"); + assert_eq!(dashes(&u, r), [5.0, 2.0]); +} + +/// An odd-length pattern draws twice over (`[4, 2, 1]` is `[4, 2, 1, 4, 2, 1]`): editing its Dash +/// writes the repeat out, so only the first dash changes and every gap stays as drawn. +#[test] +fn an_odd_length_pattern_keeps_its_repeat() { + let (mut u, r) = scene(json!([4, 2, 1])); + type_into(&mut u, "stroke_dash", "6"); + assert_eq!(dashes(&u, r), [6.0, 2.0, 1.0, 4.0, 2.0, 1.0]); + type_into(&mut u, "stroke_gap", "3"); + assert_eq!(dashes(&u, r), [6.0, 3.0, 1.0, 4.0, 2.0, 1.0]); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 137f2ae6d..a1df8e750 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -34,6 +34,7 @@ pub mod credits; pub mod import_report; pub mod missing_fonts; pub mod plugins_ui; +pub mod sampled_color; pub mod updates_ui; pub mod variables_panel; @@ -209,6 +210,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { crate::pending_open::pending_opens(app, ctx); crate::import_report::show(app, ctx); crate::missing_fonts::show(app, ctx); + crate::sampled_color::show(app, ctx); let t = Tokens::get(ctx); if app.ui.about { let mut open = true; diff --git a/crates/ui-egui/src/dialogs/sampled_color.rs b/crates/ui-egui/src/dialogs/sampled_color.rs new file mode 100644 index 000000000..747a63534 --- /dev/null +++ b/crates/ui-egui/src/dialogs/sampled_color.rs @@ -0,0 +1,136 @@ +//! A colour picked with the eyedropper while holding Cmd/Ctrl+Shift becomes a colour style or a +//! colour variable, as in the reference app: the eyedropper asks for this window +//! (`dialog.createFromColor`), which asks which to make, what to call it and (a variable) in which +//! collection, then runs `style.create` or `variable.create`. + +use egui::{Context, RichText}; +use figcraft_color::{Color, Paint}; +use serde_json::{Value, json}; + +use super::window; +use crate::FigApp; +use crate::i18n::tr; +use crate::theme::Tokens; +use crate::widgets; + +/// The window's state: the colour, what to make of it and its name. +#[derive(Clone, Debug, PartialEq)] +pub struct SampledColor { + /// The document it was picked in: the window goes when another is in front. + pub uid: u64, + /// `#rrggbb`. + pub color: String, + /// A variable (else a style). + pub variable: bool, + pub name: String, + /// The local collection a variable goes in (`None`: a new one). + pub collection: Option, +} + +/// `dialog.createFromColor {color}` from the eyedropper: the window for the document in front, with +/// the next free style name ("Color 1"…) and its first collection. +pub fn request(app: &mut FigApp, data: &Value) { + let Some(color) = data.get("color").and_then(Value::as_str).and_then(Color::from_hex) else { return }; + let Ok(ds) = app.session.ds() else { return }; + let collection = figcraft_engine::library::local_collections(&ds.doc).next().map(|c| c.id.clone()); + app.ui.sampled_color = Some(SampledColor { + uid: ds.uid, + color: color.to_css_hex(), + variable: false, + name: crate::style_picker::next_name(&ds.doc, "Color"), + collection, + }); +} + +/// The command that makes what `s` asks for: a paint style, a colour variable in its collection, +/// or (no collection) a new collection and the variable in it, one undo step. +pub fn create_params(app: &FigApp, s: &SampledColor) -> (&'static str, Value) { + let name = s.name.trim(); + if !s.variable { + return ("style.create", json!({"type": "paint", "name": name, "value": s.color})); + } + let variable = |collection: Value| json!({"collection": collection, "name": name, "type": "color", "value": s.color}); + match &s.collection { + Some(c) => ("variable.create", variable(json!(c))), + None => { + let taken = |n: &str| app.session.doc().is_ok_and(|d| d.variables.collections.iter().any(|c| c.name == n)); + let collection = crate::variables_panel::unique("Collection", taken); + let new = json!({"command": "variable.createCollection", "params": {"name": collection, "modes": ["Mode 1"]}, "as": "collection"}); + let add = json!({"command": "variable.create", "params": variable(json!({"$ref": "collection"}))}); + ("command.batch", json!({"label": "Create variable", "commands": [new, add]})) + } + } +} + +/// A variable's collection: one of the file's, or a new one. +fn collection_picker(ui: &mut egui::Ui, s: &mut SampledColor, collections: &[(String, String)]) { + let shown = s.collection.as_ref().and_then(|id| collections.iter().find(|c| &c.0 == id)).map_or(tr("New collection"), |c| c.1.as_str()); + let resp = widgets::dropdown(ui, shown, 260.0, None); + crate::right::menu_popup(&resp).show(|ui| { + for (id, label) in collections { + if crate::right::menu_item(ui, label, "") { + s.collection = Some(id.clone()); + } + } + if crate::right::menu_item(ui, tr("New collection"), "") { + s.collection = None; + } + }); +} + +/// The window: a swatch of the colour, Style or Variable, its name, a variable's collection, +/// Cancel and Create (Enter creates). +pub fn show(app: &mut FigApp, ctx: &Context) { + let Some(mut s) = app.ui.sampled_color.clone() else { return }; + if app.session.ds().map(|d| d.uid).ok() != Some(s.uid) { + app.ui.sampled_color = None; + return; + } + let t = Tokens::get(ctx); + let collections: Vec<(String, String)> = app + .session + .doc() + .map(|d| figcraft_engine::library::local_collections(d).map(|c| (c.id.clone(), c.name.clone())).collect()) + .unwrap_or_default(); + let (mut open, mut cancel, mut create) = (true, false, false); + window(tr("Create from color"), ctx).id(egui::Id::unique("Create from color")).open(&mut open).show(ctx, |ui| { + ui.set_width(280.0); + ui.horizontal(|ui| { + let paint = Color::from_hex(&s.color).map(Paint::solid); + if let Some(p) = &paint { + widgets::swatch(ui, p, 24.0); + } + ui.label(RichText::new(s.color.trim_start_matches('#').to_uppercase()).color(t.text)); + }); + ui.add_space(8.0); + if widgets::radio(ui, !s.variable, tr("Style")) { + s.variable = false; + } + if widgets::radio(ui, s.variable, tr("Variable")) { + s.variable = true; + } + ui.add_space(6.0); + let name = ui.add(egui::TextEdit::singleline(&mut s.name).hint_text(tr("Name")).desired_width(260.0)); + widgets::select_all_on_focus(ui, &name, &s.name); + if ui.memory(|m| m.focused().is_none()) { + name.request_focus(); + } + if s.variable { + ui.add_space(6.0); + collection_picker(ui, &mut s, &collections); + } + ui.add_space(10.0); + let ready = !s.name.trim().is_empty(); + ui.horizontal(|ui| { + cancel = widgets::button(ui, tr("Cancel"), false, true).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)); + let enter = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + create = (widgets::button(ui, tr("Create"), true, ready).clicked() || enter) && ready; + }); + }); + let made = create && { + let (cmd, params) = create_params(app, &s); + app.run(cmd, params).is_some() + }; + // A name already taken leaves the window open to change it (the error says why). + app.ui.sampled_color = (open && !cancel && !made).then_some(s); +} diff --git a/crates/ui-egui/src/dialogs/variables_panel.rs b/crates/ui-egui/src/dialogs/variables_panel.rs index 96c940865..926c3a2f5 100644 --- a/crates/ui-egui/src/dialogs/variables_panel.rs +++ b/crates/ui-egui/src/dialogs/variables_panel.rs @@ -281,7 +281,7 @@ fn rename_field(ui: &mut Ui, st: &mut Editor, target: &Rename, value: &str, rect } /// The first of `base`, `base 2`, `base 3`… that `taken` doesn't hold. -fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { +pub(crate) fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { (1..10_000).map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) } diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs index 960c6c65f..ca1234cfe 100644 --- a/crates/ui-egui/src/export_preview_tests.rs +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -95,7 +95,7 @@ fn export_rows_offer_a_size_for_images_only() { } /// The text painted this frame, with the centre of each piece. -fn labelled(u: &mut crate::layers_drag_tests::Ui) -> Vec<(String, egui::Pos2)> { +pub(crate) fn labelled(u: &mut crate::layers_drag_tests::Ui) -> Vec<(String, egui::Pos2)> { fn walk(shape: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { match shape { egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())), @@ -112,7 +112,7 @@ fn labelled(u: &mut crate::layers_drag_tests::Ui) -> Vec<(String, egui::Pos2)> { out } -fn click(u: &mut crate::layers_drag_tests::Ui, p: egui::Pos2) { +pub(crate) fn click(u: &mut crate::layers_drag_tests::Ui, p: egui::Pos2) { u.frame(vec![egui::Event::PointerMoved(p)]); u.button(p, true); u.button(p, false); diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index c528ec67f..e2054bc84 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -344,6 +344,47 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL); } +/// #1673: the UI runs a queued request only if the transport hasn't given up on it, and marks it +/// started first, so a timeout can tell "not applied" from "still running". Both from a frame and +/// from a hidden window's logic pass. +#[test] +fn a_request_the_transport_gave_up_on_is_never_applied() { + let (mut h, text) = document(); + h.settle(); + let (tx, rx) = std::sync::mpsc::channel(); + let rename = |id: u64, name: &str| ControlRequest { + id: json!(id), + method: "engine.execute".into(), + params: json!({"command": "node.set", "params": {"ids": [text.clone()], "props": {"name": name}}}), + reply: tx.clone(), + state: Default::default(), + }; + let node = figcraft_doc::NodeId(text.as_u64().unwrap()); + let name = |h: &Harness| h.app.session.doc().unwrap().node(node).unwrap().name.clone(); + let before = name(&h); + let mut hidden = RawInput::default(); + hidden.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() }); + for on_screen in [true, false] { + let gave_up = rename(1, "late"); + gave_up.state.cancel_if_open(); + let wanted = rename(2, "wanted"); + let gate = wanted.state.clone(); + h.app.control_queue.extend([gave_up, wanted]); + if on_screen { + h.frame(vec![]); + } else { + let ctx = h.ctx.clone(); + let _ = ctx.run_logic(&hidden, |ctx| crate::control::background(&mut h.app, ctx)); + } + let reply = rx.try_recv().expect("the wanted request is answered"); + assert_eq!(reply.id, json!(2), "on screen: {on_screen}"); + assert!(rx.try_recv().is_err(), "nothing answers the cancelled one"); + assert_eq!(gate.state(), crate::RequestState::Running, "started before it ran"); + assert_eq!(name(&h), "wanted"); + h.app.session.execute("node.set", &json!({"ids": [text.clone()], "props": {"name": before.clone()}})).unwrap(); + } +} + /// Asserts a hidden window answers edits and inspection at once, while a screenshot is /// parked with a deadline and times out once instead of waiting for a frame. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3464de0b3..b87ccba58 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -90,6 +90,8 @@ mod crop_image_tests; #[cfg(test)] mod cursors_tests; #[cfg(test)] +mod dash_pattern_tests; +#[cfg(test)] mod design_paste_tests; #[cfg(test)] mod dev_mode_tests; @@ -221,6 +223,8 @@ mod proto_responsive_tests; #[cfg(test)] mod resources_tests; #[cfg(test)] +mod sampled_color_tests; +#[cfg(test)] mod save_as_tests; #[cfg(test)] mod select_all_tests; @@ -257,6 +261,10 @@ mod vector_panel_tests; #[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] +mod vote_history_ui_tests; +#[cfg(test)] +mod vote_voters_tests; +#[cfg(test)] mod web_clipboard_tests; #[cfg(test)] mod web_save_tests; @@ -284,6 +292,7 @@ pub use dialogs::credits; pub use dialogs::import_report; pub use dialogs::missing_fonts; pub use dialogs::plugins_ui; +pub use dialogs::sampled_color; pub use dialogs::updates_ui; pub use dialogs::variables_panel; pub use home::home_recents; @@ -498,6 +507,8 @@ pub struct UiState { /// Files the user opened whose fonts are yet to be checked (by document uid). pub font_checks: Vec, pub missing_fonts: Option, + /// The eyedropper's Cmd/Ctrl+Shift pick, to make a colour style or variable of. + pub sampled_color: Option, pub screenshot_requests: Vec<(u64, String)>, pub frame_ms: f64, pub render_ms: f64, @@ -665,6 +676,7 @@ impl Default for UiState { import_report: None, font_checks: Vec::new(), missing_fonts: None, + sampled_color: None, screenshot_requests: Vec::new(), frame_ms: 0.0, render_ms: 0.0, diff --git a/crates/ui-egui/src/panels/right/stroke.rs b/crates/ui-egui/src/panels/right/stroke.rs index ef7a3648d..e4c4c8ae2 100644 --- a/crates/ui-egui/src/panels/right/stroke.rs +++ b/crates/ui-egui/src/panels/right/stroke.rs @@ -187,6 +187,22 @@ fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option