diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dfa31d9..1968076f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,7 @@ -# CI for pull requests and main: format, clippy, tests, dependency layering, asset attribution and -# the wasm32 check (the same gates as `cargo xtask ci`). Linux only: macOS is built and tested only -# by release.yml (the org's macOS runners are kept for releases; craftrules release/playbook.md), -# and Windows/FreeBSD packaging have their own workflows. name: CI +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. on: push: branches: [main] @@ -13,76 +11,21 @@ on: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - # Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the - # debug info: faster codegen and links, and a smaller target dir. - CARGO_PROFILE_DEV_DEBUG: line-tables-only - -# A newer push to a PR branch cancels its older run. Runs on main are never cancelled, so every -# main state that gets a run is checked to the end. +# A newer push to a PR branch cancels its older run; every push to main runs to the end. concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - test: - timeout-minutes: 35 - name: test (ubuntu-latest) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Install Linux system dependencies (egui/winit/rfd) - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ - libgl1-mesa-dev libgtk-3-dev - - - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - with: - components: clippy, rustfmt - - # Only main saves caches; PRs restore main's, so PR caches don't evict main's. - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - cache-on-failure: true - - - name: Format - run: cargo fmt --all -- --check - - - name: Clippy - run: cargo clippy --workspace --all-targets --locked -- -D warnings - - # Without craft-fonts (the optional CRAFT_FONTS_DIR input, which .cargo/config.toml points at - # a sibling checkout that CI doesn't have): everything must build and pass without it. - - name: Test - run: cargo test --workspace --locked - - - name: Dependency layering - run: cargo run --locked -q -p xtask -- layers - - - name: Asset attribution - run: cargo run --locked -q -p xtask -- assets - - wasm: - timeout-minutes: 30 - name: wasm32 check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - with: - key: wasm32 - save-if: ${{ github.ref == 'refs/heads/main' }} - cache-on-failure: true - # The engine, the UI crate and apps/figcraft-web must keep compiling for the browser. - - name: Check wasm-safe crates - run: cargo run --locked -q -p xtask -- wasm + gate: + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter): + # crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts' + # pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin + # once one commit has both (storytold/craft-fonts#42). + craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + packages: >- + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev + libgtk-3-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index ff15bfbc..3b03a99c 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -4,12 +4,10 @@ # before it can fail a release. Nothing is uploaded and no secrets are used. name: FreeBSD -# The VM build takes one to several hours, so it runs nightly on main rather than on every push -# or dependency change: a drifted dependency still shows up within a day. PRs that change the -# FreeBSD packaging itself still run it. +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: - schedule: - - cron: "17 2 * * *" push: branches: [release] pull_request: @@ -17,6 +15,8 @@ on: - .github/workflows/freebsd.yml - packaging/freebsd/** - packaging/env.sh + schedule: + - cron: "17 2 * * *" workflow_dispatch: concurrency: diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ecee45a6..e78fe1ea 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -11,7 +11,8 @@ permissions: concurrency: group: sonarcloud-${{ github.ref }} - cancel-in-progress: true + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: sonarcloud: diff --git a/Cargo.lock b/Cargo.lock index 27cb0d4e..fabd85f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2613,8 +2613,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "krilla" version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "base64 0.22.1", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index 95c956d5..65809315 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,11 +103,15 @@ todo = "deny" unimplemented = "deny" unreachable = "deny" -[profile.dev.package."*"] -opt-level = 2 - +# Workspace crates keep file:line in panic backtraces; dependencies carry no debug info. Both make +# the test build faster and the target dir smaller. [profile.dev] opt-level = 1 +debug = "line-tables-only" + +[profile.dev.package."*"] +opt-level = 2 +debug = false [profile.release] lto = "thin" @@ -124,3 +128,8 @@ codegen-units = 1 debug = false strip = true panic = "abort" + +# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a +# panic while drawing unwinds without aborting. +[patch.crates-io] +krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" } diff --git a/ROADMAP.md b/ROADMAP.md index f43cd015..05b7ed65 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-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a board area row and progress entry; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); 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 @@ -50,7 +50,7 @@ against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40– ~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h), basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h); -native speakers' reviews of the six language drafts need people. +native speakers' reviews of the nine language drafts need people. ## By dimension @@ -63,7 +63,7 @@ native speakers' reviews of the six language drafts need people. | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | -| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | +| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | | Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -86,13 +86,14 @@ native speakers' reviews of the six language drafts need people. | Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) | | Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) | | Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) | -| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) | +| Dev Mode / inspect | 97% | 76% | 2–4 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | | Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | +| Boards (whiteboarding; not in the weighted numbers) | ~35% | ~20% | 30–60 | [boards-parity](docs/boards-parity.md) | ## Languages @@ -126,14 +127,25 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. -4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). +4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)). 5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. ## Progress log +- **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). +- **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. +- **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged. +- **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged. +- **2026-10-15** · Removing someone from a live session (M9, #338): the host or the file's owner removes a person from the avatar's menu or with `collab.removePerson`; they keep their copy and can't rejoin while the session runs. Numbers unchanged. +- **2026-10-15** · Languages (M10, #257, #263, #469, #477, #288): Russian (one/few/many plurals) and Turkish catalogs reviewed by native speakers, Czech and Indonesian drafts, the Japanese draft completed; Preferences ▸ Language lists 12 languages and Latin American Spanish. Numbers unchanged. +- **2026-10-15** · Zoom previews (M8, #312): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it. Numbers unchanged. +- **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged. +- **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged. +- **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. +- **2026-10-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. @@ -316,6 +328,58 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | +| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | +| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | +| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged | +| 2026-10-15 | minor | Removing someone from a live session (#338): right-click a person's avatar ▸ Remove *name* from session, or `collab.removePerson {client}`, when you host the session or own the file on a server; the session ends for them (they keep their copy), the code or link they hold no longer gets them back in while it runs, and everyone else stays. Numbers unchanged | +| 2026-10-15 | minor | Pages list (#272, #258): a long pages list scrolls in its own area, so the layers below stay in view; page and layer drops count only inside the list's visible rows. Numbers unchanged | +| 2026-10-15 | minor | Variables export in the browser (#368): Export… ▸ Variables JSON / CSS custom properties did nothing in the web build and now arrives as a download; importing there still needs a file picker. Numbers unchanged | +| 2026-10-15 | minor | Font picker keys (#352, #345): Up and Down step through the font families from the current one, keeping the highlight in view and previewing it on the canvas; Enter applies it, Escape leaves the text as it was. Numbers unchanged | +| 2026-10-15 | minor | Leaving Design ends the pen path (#308, #307): switching to Prototype or Inspect while the Pen draws ends the path and goes back to Move (pressing Design again keeps drawing), and a click on the toolbar no longer reaches the canvas under it. Numbers unchanged | +| 2026-10-15 | minor | Vector edit mode keeps the canvas still (#322): the edit mode hints in the Design panel wrap, so entering edit mode and picking a region no longer shift the canvas by about 25 points in the middle of the click (which dragged the region picked). Numbers unchanged | +| 2026-10-15 | minor | Drop shadows under a background blur (#317, #255): a layer with both casts its shadows from what it paints (a translucent fill casts a lighter shadow than an opaque one) and reads the backdrop only after drawing them, so a shadow shown behind transparent areas is seen through the blur. Numbers unchanged | +| 2026-10-15 | minor | When the window can't open (#306, #261): Windows release builds have no console, so a graphics failure at start quit without a word; the error is now logged and shown in a message box in the interface language, with what to try (unset `WGPU_BACKEND`, update the driver, OpenGL) and where to report it, skipped with `--control`. Numbers unchanged | +| 2026-10-15 | minor | Crashes on Windows (#271, #314, #337; #224 and two more): a text field focused before it was drawn aborted the screen-reader layer; double-clicking a page, creating a variable collection and renaming a style folder no longer close the app. Numbers unchanged | +| 2026-10-15 | minor | Deeply nested layers (#330, #210): one depth guard (256) on the three recursive layout paths, so a deep file can't run the layout out of stack. Numbers unchanged | +| 2026-10-15 | minor | Image memory budgets (#326; #203, #189): decoded images and a file's images have budgets; the decode cache evicts its least recently used entries. Numbers unchanged | +| 2026-10-15 | minor | macOS file dialogs (#253, #250) no longer re-enter the event loop; a dialog's result goes to the file and window it was opened for, and dialogs are parented to the main window. Numbers unchanged | +| 2026-10-15 | minor | Recovery copies (#297, #303, #331, #336; #202, #185, #187): a copy counts only after its write succeeds and a failed one is retried; saves and closes wait for the write in flight; copies keep version history and branches; a recovered copy stays until this run has its own. Numbers unchanged | +| 2026-10-15 | minor | Text edits (#280, #278) no longer roll back earlier changes; a drag released off the canvas ends at the pointer's last canvas position. Numbers unchanged | +| 2026-10-15 | minor | Rollback (#282, #301; #196): a failed command rolls back during a drag or other gesture too; a failed batch keeps the redo steps. Numbers unchanged | +| 2026-10-15 | minor | Atomic save and export (#284): staging files are uniquely named and created exclusively, so a file or link already at the old fixed staging name is never written through, and two writers of one file no longer share one. Numbers unchanged | +| 2026-10-15 | minor | Alt-drag copies (#283) snap to their original, and Duplicate (Cmd+D) then repeats the last copy's offset. Numbers unchanged | +| 2026-10-15 | minor | The pen (#277) shows its Bezier handles while a point is dragged out. Numbers unchanged | +| 2026-10-15 | minor | Corner handles (#293, #292): a press on a corner handle resizes instead of picking the corner-radius handle. Numbers unchanged | +| 2026-10-15 | minor | New text (#372, #362) takes a readable colour from the frame it is drawn in, not only the page (text drawn in a white frame on a dark page was white). Numbers unchanged | +| 2026-10-15 | minor | Outline stroke (#386) works on every selected layer. Numbers unchanged | +| 2026-10-15 | minor | Send to back (#444) counts per parent when the selection spans frames. Numbers unchanged | +| 2026-10-15 | minor | Align buttons (#276) are off when the selection has nothing to align to. Numbers unchanged | +| 2026-10-15 | minor | Gradient type icons (#275) show their gradient. Numbers unchanged | +| 2026-10-15 | minor | Corner resize cursors (#388) stay diagonal at any proportions; the text selection highlight (#390) stays inside the box on the last line. Numbers unchanged | +| 2026-10-15 | minor | New pages (#311, #295) take the current page's background; the theme still decides a new file's page. Numbers unchanged | +| 2026-10-15 | minor | A slice's export preview (#265) shows the content under it. Numbers unchanged | +| 2026-10-15 | minor | SVG export (#327, #325) keeps a nested frame's fill and stroke inside its parent's clip. Numbers unchanged | +| 2026-10-15 | minor | Home screen (#318, #324): recent files notice files deleted while it is open; a long path no longer draws over the file name. Numbers unchanged | +| 2026-10-15 | minor | The Small caps sample in Type settings (#355) draws from capitals, no longer a box where the UI font lacks the small-capital letter. Numbers unchanged | +| 2026-10-15 | minor | Web clipboard (#375): copy and paste in the browser go through the system clipboard, so a paste after a copy brings the copied layers instead of whatever text the clipboard held. Numbers unchanged | +| 2026-10-15 | minor | Zoom previews (#312): when renders from scratch are slow, a new zoom first shows the whole view coarsely instead of the page colour, then the refined image; images of another file or page never show. Numbers unchanged | +| 2026-10-15 | minor | Hidden windows (#313, #332; #269): a minimised or covered window answers control and MCP requests (commands, inspection, `ui.render`) at once, each window on its own; screenshots and input still wait for it to be on screen. Numbers unchanged | +| 2026-10-15 | minor | Server (#340, #302): a compaction cut short closes the file instead of confirming lost edits (#183); each team library version is kept in its own file (#206). Numbers unchanged | +| 2026-10-15 | minor | Counters (#343, #344, #353; #309): collab sequence numbers, layer name counters and opacity digits can't overflow. Numbers unchanged | +| 2026-10-15 | minor | Commands refuse bad indexes and ids (#475, #384, #310): `page.select` and `doc.switch`, `effect.set` and `export.update` / `export.remove`, `page.move` with a non-page id. Numbers unchanged | +| 2026-10-15 | minor | Find and replace (#358, #361) changes every occurrence, case-insensitively, also when the replacement contains the search text. Numbers unchanged | +| 2026-10-15 | minor | The gradient shorthand (#254, #252) is read by the engine, so the CLI, control channel and MCP share it. Numbers unchanged | +| 2026-10-15 | minor | .fig import (#279, #281): resized instances lay out their layers by their constraints (#266); explicit variable modes whose collection is named by its asset key, as library collections are, come across (#267). Numbers unchanged | +| 2026-10-15 | minor | Colour emoji (#300, #268) draw from their own colour tables (sbix and CBDT/EBDT bitmap strikes, COLR v0 layers), a colour face tried first; COLR v1 still draws outlines. Numbers unchanged | +| 2026-10-15 | minor | macOS (#290, #289): `.figcraft` files open from Finder, Open With and the Dock. Numbers unchanged | +| 2026-10-15 | minor | Windows (#294, #291): the first preferred language alone picks the UI language. Numbers unchanged | +| 2026-10-15 | minor | Unix font discovery (#474) follows `XDG_DATA_HOME`. Numbers unchanged | +| 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged | +| 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged | +| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) | +| 2026-10-22 | minor | Type settings keeps its width (#1029): in languages whose labels run past the 110 px label column (French "Retrait de paragraphe", "Espacement de liste"), the List spacing, Paragraph indent, Max lines and decoration rows were wider than the popover, which then widened every frame while open. Those fields now take the width left after their label; English is unchanged. Regression test in `type_settings_tests`. Numbers unchanged. | +| 2026-10-22 | minor | `fill`/`stroke` props accept `"none"` (either case) and `null` as no paint (M1, #1036): mapped to an empty fills/strokes list, as `fills: []` already did (undocumented), so an outline-only shape is one call; `KEYS` and `docs/mcp.md` document it, the error names the accepted forms, `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged | | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | @@ -331,6 +395,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | | 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. | +| 2026-10-15 | minor | Dev Mode tail (M6, gap 16): import lines for connected components inside generated layer trees (definitions, slots and swapped-in instances too); `dev.compare {version}` and the compare view's Compare with menu compare with any saved version; behaviour suite (5 tests; 4 failed before the fixes): an inspect-only Dev Mode canvas with menus, shortcuts, nudges, opacity keys and paste refusing design edits, toolbar drawing tools returning to the design, Changed in the ready list (`inspect.readyForDev {status: changed}`), statuses only on sections and top-level frames; read-only plugins' panels from the Inspect tab. Dev Mode 96%/72% → 97%/76%, 0–1 h → 2–4 h (measurements kept on the canvas and a read-only layers panel counted as missing); header totals not recomputed | | 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. | | 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 4c162dbb..3fe17e80 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -1,6 +1,6 @@ //! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value` //! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s. -//! Unknown options are usage errors. +//! Unknown options are usage errors; `--help` or `-h` is [`CliError::Help`]. use std::collections::BTreeMap; @@ -16,6 +16,8 @@ pub struct Args { impl Args { pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult { let mut a = Args::default(); + // The first bad option, reported once the rest of the line is checked for `--help`. + let mut bad: Option = None; let mut it = argv.iter(); while let Some(arg) = it.next() { let name = if let Some(n) = arg.strip_prefix("--") { @@ -30,19 +32,27 @@ impl Args { Some((n, v)) => (n, Some(v.to_string())), None => (name, None), }; + // `--help` / `-h` anywhere among the options (not as an option's value) asks for help. + if inline.is_none() && matches!(name, "help" | "h") { + return Err(CliError::Help); + } if valued.contains(&name) { - let v = match inline { - Some(v) => v, - None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?, - }; - a.values.entry(name.to_string()).or_default().push(v); + match inline.or_else(|| it.next().cloned()) { + Some(v) => a.values.entry(name.to_string()).or_default().push(v), + None => { + bad.get_or_insert(CliError::Usage(format!("`{arg}` needs a value"))); + } + } } else if flags.contains(&name) && inline.is_none() { a.flags.push(name.to_string()); } else { - return Err(CliError::Usage(format!("unknown option `{arg}`"))); + bad.get_or_insert(CliError::Usage(format!("unknown option `{arg}`"))); } } - Ok(a) + match bad { + Some(e) => Err(e), + None => Ok(a), + } } pub fn value(&self, k: &str) -> Option { @@ -135,4 +145,18 @@ mod tests { let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap(); assert_eq!(a.positional, ["-5"]); } + + #[test] + fn help_is_asked_for_anywhere_but_as_a_value() { + // Also after a bad option: help wins over the usage error (#1161 review). + for argv in + [&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"], &["--bogus", "--help"], &["--json=1", "-h"]] + { + assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}"); + } + let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap(); + assert_eq!((a.value("filter").as_deref(), a.value("o").as_deref()), (Some("--help"), Some("-h"))); + // `--help=x` is no request for help, just an unknown option. + assert!(matches!(Args::parse(&v(&["--help=x"]), &[], &[]), Err(CliError::Usage(_)))); + } } diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 46215599..b87aa431 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -1,5 +1,6 @@ //! The file subcommands: info, export, convert, render and run. +use std::ffi::OsStr; use std::path::Path; use std::process::ExitCode; @@ -19,7 +20,7 @@ fn load_noted(path: &str) -> CliResult<(Document, Vec)> { } fn ext(path: &str) -> String { - Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() + Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("").to_ascii_lowercase() } fn image_format(e: &str) -> Option { @@ -207,10 +208,13 @@ pub fn info(path: &str, as_json: bool) -> CliResult { Ok(ExitCode::SUCCESS) } +/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`], +/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters +/// and 200 bytes (file systems allow 255, and a number and the extension still fit). fn sanitize(name: &str) -> String { - let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect(); - let s = s.trim().trim_matches('.').to_string(); - if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() } + let s = figcraft_engine::cmd::file::safe_file_name(name); + let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace()); + if s.is_empty() { "layer".into() } else { s.char_indices().take(100).take_while(|(i, c)| i + c.len_utf8() <= 200).map(|(_, c)| c).collect() } } /// What `export` exports and how. @@ -411,6 +415,14 @@ mod tests { assert_eq!(sanitize("Home / Hero"), "Home _ Hero"); assert_eq!(sanitize("../.."), "_"); assert_eq!(sanitize(""), "layer"); + // The same characters the app's export keeps (#1154): only what file systems refuse goes. + assert_eq!(sanitize("Homepage - desktop (live + verbeteringen)"), "Homepage - desktop (live + verbeteringen)"); + assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é"); + assert_eq!(sanitize("a\\b:c*d?e\"fh|i\tj"), "a_b_c_d_e_f_g_h_i_j"); + assert_eq!(sanitize(" .hidden. "), "hidden"); + assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed"); + assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100); + assert_eq!(sanitize(&"\u{1F600}".repeat(100)), "\u{1F600}".repeat(50), "at most 200 bytes, whole characters"); } #[test] diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index badc5bc3..66627673 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -27,7 +27,7 @@ USAGE: figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... figcraft-cli plugin publish --server ws://host:port/[?key=KEY] figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]] - figcraft-cli help | --help | --version + figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too) Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are @@ -46,10 +46,11 @@ the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell hi `--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders must exist) confine the files a subcommand touches: reads only inside a read or write root, writes only inside a write root, everything else refused -- so giving no write root denies writing -altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`, -`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes -as the user who started it, as before. A running app confines itself: start it with -`figcraft --control PORT` and the same flags (`--attach` refuses them). +altogether. They reach the headless side only: `mcp` takes them to confine the agent's commands, +and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a +subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is +confined by the app it attaches to, not by flags passed to the CLI: start that app with +`figcraft --control PORT` and the same roots (`--attach` refuses them). See docs/cli.md and docs/mcp.md."; /// The automation roots a file subcommand was given, if any. Omitting both leaves the run @@ -74,6 +75,10 @@ fn main() -> ExitCode { let argv: Vec = std::env::args().skip(1).collect(); match run(&argv) { Ok(code) => code, + Err(CliError::Help) => { + println!("{USAGE}"); + ExitCode::SUCCESS + } Err(CliError::Usage(msg)) => { eprintln!("error: {msg}\n\n{USAGE}"); ExitCode::from(2) @@ -87,6 +92,8 @@ fn main() -> ExitCode { #[derive(Debug)] pub enum CliError { + /// `--help` or `-h` after a subcommand: usage on stdout, exit code 0. + Help, /// Bad arguments: usage is printed, exit code 2. Usage(String), /// The operation failed: exit code 1. diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/it/cli.rs similarity index 82% rename from apps/figcraft-cli/tests/cli.rs rename to apps/figcraft-cli/tests/it/cli.rs index eb42b8d8..b1aeb54a 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/it/cli.rs @@ -1,5 +1,4 @@ //! The binary end to end. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::io::Write; use std::process::{Command, Stdio}; @@ -40,6 +39,40 @@ fn bad_arguments_exit_2_without_panicking() { assert_eq!(out.status.code(), Some(1)); } +/// `--help` / `-h` after a subcommand prints the usage on stdout and exits 0, as it does first +/// (#1155), whatever else is on the line; nothing runs. +#[test] +fn help_after_a_subcommand_prints_the_usage() { + for args in [ + &["--help"][..], + &["-h"], + &["help"], + &["mcp", "--help"], + &["mcp", "-h"], + &["info", "--help"], + &["export", "--help"], + &["export", "x.figcraft", "-o", "out", "--help"], + &["convert", "-h"], + &["render", "--help"], + &["run", "--help"], + &["commands", "--help"], + &["plugin", "publish", "--help"], + &["perf", "--help"], + &["mcp", "--attach", "notaport", "--help"], + &["mcp", "--bogus", "--help"], + ] { + let out = bin().args(args).stdin(Stdio::null()).output().unwrap(); + assert_eq!(out.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("USAGE") && text.contains("figcraft-cli mcp"), "{args:?}: {text}"); + assert!(out.stderr.is_empty(), "{args:?}: nothing on stderr, got {}", String::from_utf8_lossy(&out.stderr)); + } + // A value that happens to be `-h` is still a value, not a request for help. + let out = bin().args(["commands", "--filter", "--help"]).output().unwrap(); + assert_eq!(out.status.code(), Some(0)); + assert!(!String::from_utf8_lossy(&out.stdout).contains("USAGE"), "--filter took `--help` as its text"); +} + #[test] fn run_export_info_convert() { let d = tmp("run"); @@ -81,6 +114,30 @@ fn run_export_info_convert() { std::fs::remove_dir_all(&d).ok(); } +/// `export` into a folder names the files as the app's export does (#1154): brackets, `+`, `&`, +/// `%`, `#` and commas stay, what file systems refuse becomes `_`. +#[test] +fn export_file_names_keep_what_the_app_keeps() { + let d = tmp("names"); + let script = d.join("s.json"); + let names = ["Homepage - desktop (live + verbeteringen)", "Q&A, 50% #1 [v2]", "a:b*c?d|e"]; + let steps: Vec = names + .iter() + .enumerate() + .map(|(i, n)| serde_json::json!({"command": "frame.create", "params": {"x": i * 200, "y": 0, "width": 20, "height": 20, "name": n}})) + .collect(); + std::fs::write(&script, serde_json::to_string(&steps).unwrap()).unwrap(); + let file = d.join("doc.figcraft"); + assert!(bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap().status.success()); + let dir = d.join("out"); + let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + for want in ["Homepage - desktop (live + verbeteringen).png", "Q&A, 50% #1 [v2].png", "a_b_c_d_e.png"] { + assert!(dir.join(want).is_file(), "{want} in {:?}", std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).collect::>()); + } + std::fs::remove_dir_all(&d).ok(); +} + #[test] fn run_takes_the_documented_gradient_shorthand() { // #252: only MCP used to accept it; `run` said "`fills` must be paints or colours". diff --git a/apps/figcraft-cli/tests/export_depth.rs b/apps/figcraft-cli/tests/it/export_depth.rs similarity index 97% rename from apps/figcraft-cli/tests/export_depth.rs rename to apps/figcraft-cli/tests/it/export_depth.rs index d8b3ef36..37350c1e 100644 --- a/apps/figcraft-cli/tests/export_depth.rs +++ b/apps/figcraft-cli/tests/it/export_depth.rs @@ -1,5 +1,4 @@ //! Depth refusal through the actual CLI, including its exit status and destination writes. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use figcraft_doc::color::{Color, Paint}; use figcraft_doc::{Document, NodeKind}; diff --git a/apps/figcraft-cli/tests/fonts.rs b/apps/figcraft-cli/tests/it/fonts.rs similarity index 98% rename from apps/figcraft-cli/tests/fonts.rs rename to apps/figcraft-cli/tests/it/fonts.rs index ef5c638f..a48056bd 100644 --- a/apps/figcraft-cli/tests/fonts.rs +++ b/apps/figcraft-cli/tests/it/fonts.rs @@ -1,6 +1,5 @@ //! Headless runs lay text out with the installed fonts, like the desktop app, unless //! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::process::Command; diff --git a/apps/figcraft-cli/tests/it/main.rs b/apps/figcraft-cli/tests/it/main.rs new file mode 100644 index 00000000..ae4b0488 --- /dev/null +++ b/apps/figcraft-cli/tests/it/main.rs @@ -0,0 +1,12 @@ +//! The crate's integration tests, built as one test binary. + +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod cli; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod export_depth; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod fonts; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod pdf; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod pdf_print; diff --git a/apps/figcraft-cli/tests/pdf.rs b/apps/figcraft-cli/tests/it/pdf.rs similarity index 97% rename from apps/figcraft-cli/tests/pdf.rs rename to apps/figcraft-cli/tests/it/pdf.rs index 28d5ca6c..185d5b60 100644 --- a/apps/figcraft-cli/tests/pdf.rs +++ b/apps/figcraft-cli/tests/it/pdf.rs @@ -1,5 +1,4 @@ //! `figcraft-cli export --format pdf` and `convert … .pdf` end to end. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::process::Command; diff --git a/apps/figcraft-cli/tests/pdf_print.rs b/apps/figcraft-cli/tests/it/pdf_print.rs similarity index 97% rename from apps/figcraft-cli/tests/pdf_print.rs rename to apps/figcraft-cli/tests/it/pdf_print.rs index b2ce3c9a..f12725ed 100644 --- a/apps/figcraft-cli/tests/pdf_print.rs +++ b/apps/figcraft-cli/tests/it/pdf_print.rs @@ -1,5 +1,4 @@ //! `figcraft-cli export` with PDF print options: bleed, marks, CMYK; bad lengths are usage errors. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::process::Command; diff --git a/apps/figcraft-server/src/cli_tests.rs b/apps/figcraft-server/src/cli_tests.rs index 7b77f71e..7d6a6b9e 100644 --- a/apps/figcraft-server/src/cli_tests.rs +++ b/apps/figcraft-server/src/cli_tests.rs @@ -11,7 +11,7 @@ use crate::store::Store; use crate::test_util::{TempDir, config}; fn args(s: &[&str]) -> Vec { - s.iter().map(|a| a.to_string()).collect() + s.iter().map(ToString::to_string).collect() } fn run(dir: &std::path::Path, command: Command) -> Result { diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index af6e0f99..d32a1747 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -13,6 +13,7 @@ //! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its //! first damaged record (a write cut short). +use std::ffi::OsStr; use std::fs::{self, File}; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; @@ -49,7 +50,7 @@ pub fn free_bytes(path: &Path) -> Option { /// `path` with `.tmp` added: where its next version is written first. pub fn temp_of(path: &Path) -> PathBuf { - let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default(); + let mut name = path.file_name().map(OsStr::to_os_string).unwrap_or_default(); name.push(".tmp"); path.with_file_name(name) } diff --git a/apps/figcraft-server/src/hardening_tests.rs b/apps/figcraft-server/src/hardening_tests.rs index 086539ca..92b17710 100644 --- a/apps/figcraft-server/src/hardening_tests.rs +++ b/apps/figcraft-server/src/hardening_tests.rs @@ -838,3 +838,25 @@ fn a_library_publish_that_fails_keeps_serving_the_old_version() { assert_eq!(served(fetch(&mut store)), (2, v2)); assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted"); } + +#[test] +fn a_library_publish_cannot_claim_the_highest_version() { + let dir = TempDir::new("libmax"); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap(); + let mut s = Session::new(); + let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap(); + s.execute("component.create", &json!({"ids": [f]})).unwrap(); + s.execute("library.publish", &json!({})).unwrap(); + let mut lib = s.doc().unwrap().published.clone().unwrap().as_ref().clone(); + let mut archive = |version: u64| { + lib.manifest.version = version; + figcraft_engine::library_server::archive(&lib).unwrap() + }; + let high = 1u64 << 32; + assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a first publish can't claim the highest version"); + assert_eq!(store.publish_library(&file.id, &archive(high)).unwrap(), high, "the highest accepted first version goes in"); + assert_eq!(store.publish_library(&file.id, &archive(high + 1)).unwrap(), high + 1, "and a newer one still follows it"); + assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a jump to the highest version is refused"); + assert_eq!(store.publish_library(&file.id, &archive(high + 2)).unwrap(), high + 2, "later versions still go in"); +} diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index 978d9e8e..dc86d123 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -164,6 +164,64 @@ fn caps_per_address_and_for_everyone() { assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY); } +#[test] +fn caps_per_address_count_the_wider_ipv6_blocks() { + let dir = TempDir::new("block-caps"); + let mut c = open(dir.path()); + c.max_files_per_ip = 1; + c.publishes_per_hour = 1000; + let mut store = Store::open(dir.path(), c.store()).unwrap(); + // A /48 keeps 4 times one address's files: /64s in different /56s of it. + for i in 0..4 { + store.publish_open(ip(&format!("2001:db8:1:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap(); + } + let e = store.publish_open(ip("2001:db8:1:400::1"), "Five", &snapshot("Five")).unwrap_err(); + assert!(e.contains("files on this server already"), "{e}"); + // A /56 keeps twice one address's files. + store.publish_open(ip("2001:db8:2:1::1"), "One", &snapshot("One")).unwrap(); + store.publish_open(ip("2001:db8:2:2::1"), "Two", &snapshot("Two")).unwrap(); + let e = store.publish_open(ip("2001:db8:2:3::1"), "Three", &snapshot("Three")).unwrap_err(); + assert!(e.contains("files on this server already"), "{e}"); + drop(store); + // Bytes: one address keeps one file's worth, so a /48 keeps 4 files. + let probe = TempDir::new("block-caps-probe"); + let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap(); + let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes; + let dir = TempDir::new("block-caps-bytes"); + let mut s = open(dir.path()).store(); + s.hosting.max_files_per_ip = 100; + s.hosting.max_bytes_per_ip = size; + let mut store = Store::open(dir.path(), s).unwrap(); + for i in 0..4 { + store.publish_open(ip(&format!("2001:db8:3:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap(); + } + let e = store.publish_open(ip("2001:db8:3:400::1"), "One", &snapshot("One")).unwrap_err(); + assert!(e.contains("MiB it may keep"), "{e}"); +} + +#[test] +fn edits_count_the_wider_ipv6_blocks_too() { + use figcraft_collab_net::hub::Backend; + let probe = TempDir::new("block-room-probe"); + let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap(); + let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes; + let dir = TempDir::new("block-room"); + let mut s = open(dir.path()).store(); + s.hosting.max_files_per_ip = 100; + // One address keeps 2.25 files' worth, so its /56 keeps 4.5. + s.hosting.max_bytes_per_ip = size * 2 + size / 4; + let mut store = Store::open(dir.path(), s).unwrap(); + let mut ids = Vec::new(); + for i in 0..4 { + ids.push(store.publish_open(ip(&format!("2001:db8:4:{i:x}::1")), "One", &snapshot("One")).unwrap().0.id); + } + let server = Backend::open(&mut store, &ids[0]).unwrap(); + // Its own /64 has room for this, but its /56 doesn't. + let e = Backend::room(&mut store, &ids[0], &server, (size * 3 / 4) as usize).unwrap_err(); + assert!(e.contains("have reached their"), "{e}"); + Backend::room(&mut store, &ids[0], &server, (size / 4) as usize).unwrap(); +} + #[test] fn idle_files_published_without_the_key_expire() { let dir = TempDir::new("expiry"); @@ -196,6 +254,22 @@ fn idle_files_published_without_the_key_expire() { assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id)); } +#[test] +fn offline_commands_do_not_sweep_idle_files() { + let dir = TempDir::new("offline-sweep"); + let mut c = open(dir.path()); + c.idle_days = 30.0; + let mut store = Store::open(dir.path(), c.store()).unwrap(); + let (public, ..) = store.publish_open(ip("203.0.113.9"), "Public", &snapshot("Public")).unwrap(); + store.age(&public.id, 31 * 86_400); + drop(store); + let store = Store::try_open(dir.path(), c.store()).unwrap().unwrap(); + drop(store); + assert!(dir.path().join("files").join(&public.id).exists(), "a command deleted an idle file"); + let _serving = Store::open(dir.path(), c.store()).unwrap(); + assert!(!dir.path().join("files").join(&public.id).exists()); +} + #[test] fn rate_limits_and_blocks_refuse_with_a_reason() { let dir = TempDir::new("limits"); diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..ab65c2d8 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -42,7 +42,7 @@ use figcraft_collab::message::{ PluginReply, PluginReq, ReportInfo, Usage, }; use figcraft_collab::{Change, Replica, Server, same_secret}; -use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_key}; +use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_blocks, ip_key}; use figcraft_collab_net::token; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -291,7 +291,18 @@ fn valid_id(s: &str) -> bool { /// A name others may see: trimmed, no control characters, at most 256 characters. fn clean_name(s: &str) -> String { - s.trim().chars().filter(|c| !c.is_control()).take(256).collect() + s.trim().chars().filter(|c| !c.is_control() && !is_bidi_control(*c)).take(256).collect() +} + +/// A report's reason: trimmed, control and bidi characters turned into spaces, at most `MAX_REASON` characters. +fn clean_reason(s: &str) -> String { + let s: String = s.chars().map(|c| if c.is_control() || is_bidi_control(c) { ' ' } else { c }).collect(); + s.trim().chars().take(MAX_REASON).collect() +} + +/// Bidirectional format characters, which could reorder what a terminal shows. +fn is_bidi_control(c: char) -> bool { + matches!(c, '\u{61C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') } /// Take the data directory's lock: `None` when someone else holds it. @@ -302,6 +313,10 @@ pub fn lock(root: &Path) -> Result, String> { #[cfg(unix)] std::os::unix::fs::DirBuilderExt::mode(&mut dirs, 0o700); dirs.create(root).map_err(|e| format!("can't create the data directory {}: {e}", root.display()))?; + // `DirBuilder` only applies the mode to directories it creates; tighten an existing one too. + #[cfg(unix)] + fs::set_permissions(root, std::os::unix::fs::PermissionsExt::from_mode(0o700)) + .map_err(|e| format!("can't restrict the data directory {}: {e}", root.display()))?; let f = OpenOptions::new() .create(true) .truncate(false) @@ -334,16 +349,27 @@ fn write_key(root: &Path) -> Result { Ok(key) } +/// The highest version a file's first library publish may claim. Publishing counts up by one (a +/// library carried over from another server keeps its number), so no real history gets near it. +/// Later publishes may go one past the version on the server, so there is always a next one. +const MAX_FIRST_LIBRARY_VERSION: u64 = 1 << 32; + impl Store { /// Use the data directory `root` (made if missing). Fails when another server or command holds /// it. Files that can't be read are skipped and listed in [`problems`](Self::problems). pub fn open(root: &Path, cfg: StoreConfig) -> Result { - Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display())) + let mut store = Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))?; + store.sweep(); + Ok(store) } - /// [`open`](Self::open), or `None` when someone else holds the directory. + /// [`open`](Self::open) without the expiry sweep (for commands that only look or edit), or + /// `None` when someone else holds the directory. pub fn try_open(root: &Path, cfg: StoreConfig) -> Result, String> { let Some(lock) = lock(root)? else { return Ok(None) }; + for name in [KEY, BLOCKED, REPORTS] { + let _ = fs::remove_file(disk::temp_of(&root.join(name))); + } let key = load_key(root)?; let dir = root.join(FILES); fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; @@ -374,7 +400,6 @@ impl Store { store.scan(&dir); store.blocked = store.read_list::(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect(); store.reports = store.read_list(REPORTS); - store.sweep(); Ok(Some(store)) } @@ -425,12 +450,20 @@ impl Store { /// A file folder's description and links, after checking its snapshot and log. Leftover /// temporary files (writes cut short) are removed. fn check(&self, path: &Path) -> Result { - for name in [SNAPSHOT, LOG, META, LINKS] { + for name in [SNAPSHOT, LOG, META, LINKS, USAGE] { let tmp = disk::temp_of(&path.join(name)); if tmp.exists() { let _ = fs::remove_file(&tmp); } } + if let Ok(entries) = fs::read_dir(path) { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if name.starts_with("library-") && name.ends_with(".bin.tmp") { + let _ = fs::remove_file(e.path()); + } + } + } let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?; let links: Vec = serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?; @@ -514,6 +547,34 @@ impl Store { self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view)) } + /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's + /// caps it gets, and the files and bytes kept by the files published from addresses in it. An + /// address that doesn't parse counts as itself only, ×1, by its exact text. + fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> { + let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default(); + let kept: Vec<(&str, Vec<_>, u64)> = self + .files + .iter() + .filter_map(|(id, e)| { + let from = e.meta.ip.as_deref()?; + Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id))) + }) + .collect(); + let sum = |mine: &mut dyn Iterator, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n))); + let blocks = blocks_of(ip); + if blocks.is_empty() { + let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip)); + return vec![(1, files, bytes)]; + } + blocks + .into_iter() + .map(|(block, times)| { + let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block))); + (times, files, bytes) + }) + .collect() + } + /// Keep a document published without the key from `ip`, within the caps on what one address /// and everyone may keep: the file, its links and its owner token. pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> { @@ -525,19 +586,21 @@ impl Store { if !self.abuse.allow(ip, Action::Publish, Instant::now()) { return Err("too many files published from your address lately: try again in an hour".into()); } - let ip_text = ip.to_string(); - let mine: Vec<&String> = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip_text.as_str())).map(|(id, _)| id).collect(); - if mine.len() >= max_files { - return Err(format!( - "your address keeps {} files on this server already, the most it may: remove one (with its owner token) or wait until one expires", - mine.len() - )); + let blocks = self.per_block(&ip.to_string()); + for &(times, files, _) in &blocks { + if files >= max_files.saturating_mul(times as usize) { + return Err(format!( + "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires" + )); + } } - let used_ip: u64 = mine.iter().map(|id| self.bytes(id)).sum(); let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); let fits = |n: u64| { - if used_ip.saturating_add(n) > max_ip { - return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", max_ip >> 20)); + for &(times, _, used) in &blocks { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20)); + } } if used_all.saturating_add(n) > max_all { return Err("this server is full: it takes no more files for now (try again later, or another server)".into()); @@ -748,7 +811,7 @@ impl Store { /// Keep a report about `file` (from someone who joined it with `link`, at `ip`). pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { - let reason: String = reason.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REASON).collect(); + let reason = clean_reason(reason); if reason.is_empty() { return Err("say what is wrong with the file".into()); } @@ -973,6 +1036,12 @@ impl Store { if m.version <= old.version { return Err(format!("version {} is on the server already: publish again to follow it", old.version)); } + // A jump would let a client use up the numbers and leave no newer version to publish. + if m.version > old.version.saturating_add(1) { + return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1))); + } + } else if m.version > MAX_FIRST_LIBRARY_VERSION { + return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version)); } // Each version has a file of its own, written before the description names it: a failed // description write or a crash in between leaves the previous version served whole, never @@ -1090,9 +1159,11 @@ impl Store { let h = &self.cfg.hosting; let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) }; let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes); - let used_ip: u64 = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip.as_str())).map(|(id, _)| self.bytes(id)).sum(); - if used_ip.saturating_add(n) > max_ip { - return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", max_ip >> 20)); + for (times, _, used) in self.per_block(&ip) { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20)); + } } let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); if used_all.saturating_add(n) > max_all { @@ -1110,7 +1181,24 @@ impl Store { let mut issues = Vec::new(); let mut fresh_log = true; let mut kept = 0; - match disk::read_capped(&dir.join(LOG), self.cfg.max_file_bytes).and_then(|b| disk::read_log(&b)) { + let log_path = dir.join(LOG); + // A log that's there but can't be read (an IO error, or over a lowered cap) may hold + // confirmed edits: the file isn't opened, and the log is left as it is for another try. + let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) { + Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")), + r => r, + }; + // One that isn't a log is moved aside before a new one replaces it (or the file isn't opened). + let read = match read.map(|b| disk::read_log(&b)) { + Ok(Err(why)) => { + let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now())); + fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?; + Err(format!("{why}; it was moved aside to {}", aside.display())) + } + Ok(Ok(log)) => Ok(log), + Err(why) => Err(why), + }; + match read { Ok(log) if log.generation == generation => { fresh_log = false; for entry in log.entries { @@ -1167,7 +1255,10 @@ impl Store { Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } } // A new snapshot and log of the next generation, so appends start clean. - _ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?, + _ => { + let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?; + self.rewrite(file, next, &server).map_err(|e| e.why)? + } }; self.entry(file)?.log = Some(log); Ok(server) @@ -1243,7 +1334,11 @@ impl Store { fn compact(&mut self, file: &str, server: &Server) { self.compactions += 1; let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return }; - match self.rewrite(file, generation + 1, server) { + let Some(next) = generation.checked_add(1) else { + log::error!("file {file}: compaction failed: the file's generation can't go any higher"); + return; + }; + match self.rewrite(file, next, server) { Ok(log) => { if let Ok(e) = self.entry(file) { e.log = Some(log); diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index f380568c..d8c5b915 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -229,6 +229,17 @@ fn one_server_per_data_directory() { assert_eq!(again.key(), key, "the key is kept"); } +#[cfg(unix)] +#[test] +fn an_existing_data_directory_is_made_private() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new("perms"); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(crate::store::lock(dir.path()).unwrap().is_some()); + let mode = fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); +} + #[test] fn temporary_directories_are_removed() { let path = { @@ -313,3 +324,129 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() { let name = page_name(&restored); assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}"); } + +#[test] +fn a_log_that_cant_be_read_is_never_overwritten() { + let dir = TempDir::new("unreadlog"); + let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) }; + let log_path = dir.path().join(FILES); + let last = "Confirmed page name number 39 with some padding"; + let id = { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Busy"); + let mut server = store.open(&id).unwrap(); + let mut a = join(&mut server, Access::Edit); + store.changed(&id, &mut server); + for i in 0..40 { + rename(&mut server, &mut a, &format!("Confirmed page name number {i} with some padding")); + store.changed(&id, &mut server); + } + assert!(store.flush().is_empty()); + id + }; + let log_path = log_path.join(&id).join(LOG); + let before = fs::read(&log_path).unwrap(); + // The cap lowered just under the log: it can't be read now, but it must still be there after. + let lowered = StoreConfig { max_file_bytes: before.len() as u64 - 1, ..c.clone() }; + { + let mut store = Store::open(dir.path(), lowered).unwrap(); + let opened = store.open(&id); + assert!(opened.is_err(), "the file isn't opened without its log"); + assert!(!store.problems().is_empty(), "the problem is reported"); + assert_eq!(fs::read(&log_path).unwrap(), before, "the log is left as it was"); + } + let mut store = Store::open(dir.path(), c).unwrap(); + let server = store.open(&id).unwrap(); + assert_eq!(page_name(&server), last, "every confirmed edit comes back"); +} + +#[test] +fn a_log_that_isnt_one_is_moved_aside() { + let dir = TempDir::new("badlog"); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + let (id, _, _) = publish(&mut store, "Busy"); + let folder = dir.path().join(FILES).join(&id); + let junk = b"not a log at all, but maybe worth keeping".to_vec(); + fs::write(folder.join(LOG), &junk).unwrap(); + drop(store); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + store.open(&id).unwrap(); + assert!(store.problems().iter().any(|p| p.contains("moved aside")), "{:?}", store.problems()); + let kept = fs::read_dir(&folder).unwrap().filter_map(|e| e.ok()).any(|e| fs::read(e.path()).is_ok_and(|b| b == junk)); + assert!(kept, "the old log's bytes are kept beside the file"); +} + +const BIDI: [char; 12] = + ['\u{61C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}']; + +#[test] +fn names_and_report_reasons_lose_bidi_controls() { + let dir = TempDir::new("bidi"); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + for (n, m) in BIDI.into_iter().enumerate() { + let ip: std::net::IpAddr = format!("192.0.2.{}", n + 1).parse().unwrap(); + let (id, _, _) = publish(&mut store, &format!("a{m}b")); + assert_eq!(store.list(&|_| None)[0].name, "ab", "name U+{:04X}", m as u32); + store.keep_report(&id, None, ip, &format!("a{m}b")).unwrap(); + assert_eq!(store.reports().last().unwrap().reason, "a b", "reason U+{:04X}", m as u32); + store.remove(&id).unwrap(); + } + // A reason that is only control characters says nothing. + let (id, _, _) = publish(&mut store, "blank"); + for blank in ["\0", "\u{202E}", " \0\t\u{2066} "] { + assert!(store.keep_report(&id, None, "192.0.2.99".parse().unwrap(), blank).is_err(), "{blank:?}"); + } +} + +#[test] +fn a_maxed_out_generation_does_not_wrap() { + let dir = TempDir::new("maxgen"); + let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) }; + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Max"); + let path = dir.path().join(FILES).join(&id); + let (_, state) = disk::decode_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap(); + // Loading: no log goes with the snapshot, so the next generation would overflow. + fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX, &state).unwrap()).unwrap(); + fs::remove_file(path.join(LOG)).unwrap(); + assert!(store.open(&id).is_err()); + // Compacting: the open log is at the largest generation. + fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX - 1, &state).unwrap()).unwrap(); + let _ = fs::remove_file(path.join(LOG)); + let mut server = store.open(&id).unwrap(); + let mut a = join(&mut server, Access::Edit); + store.changed(&id, &mut server); + for i in 0..4 { + rename(&mut server, &mut a, &format!("P{i}")); + store.changed(&id, &mut server); + store.flush(); + } + let snap = disk::check_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap(); + assert_eq!(snap, u64::MAX); +} + +#[test] +fn every_leftover_temporary_file_is_removed_at_startup() { + let dir = TempDir::new("leftovers"); + let id = { + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + let id = publish(&mut store, "A").0; + store.flush(); + id + }; + let folder = dir.path().join(FILES).join(&id); + let leftovers = [ + dir.path().join("blocked.json.tmp"), + dir.path().join("reports.json.tmp"), + dir.path().join("server.key.tmp"), + folder.join("usage.json.tmp"), + folder.join("library-1.bin.tmp"), + ]; + for path in &leftovers { + fs::write(path, b"half").unwrap(); + } + let _store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + for path in &leftovers { + assert!(!path.exists(), "leftover not cleaned at startup: {}", path.display()); + } +} diff --git a/apps/figcraft-server/tests/header_recovery.rs b/apps/figcraft-server/tests/it/header_recovery.rs similarity index 98% rename from apps/figcraft-server/tests/header_recovery.rs rename to apps/figcraft-server/tests/it/header_recovery.rs index d728908d..bb0be1d0 100644 --- a/apps/figcraft-server/tests/header_recovery.rs +++ b/apps/figcraft-server/tests/it/header_recovery.rs @@ -2,7 +2,6 @@ //! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the //! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short //! still restores silently. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use figcraft_collab::message::{Access, ClientMsg}; use figcraft_collab::{Client, Outgoing, Replica, Server}; diff --git a/apps/figcraft-server/tests/it/main.rs b/apps/figcraft-server/tests/it/main.rs new file mode 100644 index 00000000..3027ed99 --- /dev/null +++ b/apps/figcraft-server/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, built as one test binary. + +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod header_recovery; +mod problem_retention; diff --git a/apps/figcraft-server/tests/problem_retention.rs b/apps/figcraft-server/tests/it/problem_retention.rs similarity index 100% rename from apps/figcraft-server/tests/problem_retention.rs rename to apps/figcraft-server/tests/it/problem_retention.rs diff --git a/apps/figcraft/src/args_tests.rs b/apps/figcraft/src/args_tests.rs index b274beda..04229e51 100644 --- a/apps/figcraft/src/args_tests.rs +++ b/apps/figcraft/src/args_tests.rs @@ -3,7 +3,7 @@ use crate::args::{Args, file_url_path, is_invite, parse}; fn args(a: &[&str]) -> Vec { - a.iter().map(|s| s.to_string()).collect() + a.iter().map(ToString::to_string).collect() } #[test] diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index 1c91840f..ff9c2fbe 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -16,6 +16,10 @@ const MAX_LINE: usize = 32 * 1024 * 1024; const MAX_CONNECTIONS: usize = 8; const MAX_QUEUED_REQUESTS: usize = 8; const TIMEOUT: Duration = Duration::from_secs(60); +/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for +/// example while the process has no file descriptors left, and retrying at once would keep a +/// core busy until it recovers. +const ACCEPT_RETRY: Duration = Duration::from_millis(100); /// 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) { @@ -38,7 +42,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) { eprintln!("FigCraft control server listening on 127.0.0.1:{port}"); let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || { let mut workers = Vec::new(); - for stream in listener.incoming().flatten() { + for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) { spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT); } }); @@ -47,6 +51,11 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) { } } +/// The connections `incoming` accepts, calling `wait` after each failed accept. +fn accepted(incoming: impl Iterator>, mut wait: impl FnMut()) -> impl Iterator { + incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok()) +} + fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec>, timeout: Duration) { workers.retain(|worker| !worker.is_finished()); if workers.len() >= MAX_CONNECTIONS { diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 008170fb..011b0613 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -131,6 +131,18 @@ fn idle_and_partial_frame_connections_expire() { } } +/// A failed accept is followed by a wait before the next one, so a listener that keeps failing +/// does not spin, and the connections around the failures still come through. +#[test] +fn a_failed_accept_waits_before_the_next() { + let failed = || Err(std::io::Error::other("no file descriptors left")); + let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)]; + let mut waits = 0; + let connections: Vec = accepted(incoming.into_iter(), || waits += 1).collect(); + assert_eq!(connections, [1, 2]); + assert_eq!(waits, 3, "one wait per failed accept"); +} + #[test] fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() { let pending = queue(); diff --git a/contributors/people.toml b/contributors/people.toml index 27307935..6a92a2a9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -71,3 +71,8 @@ sync_github_name = true [people.AganzeFelicite] sync_github_name = true + +[people.switch87] +real_name = "Gert Pellin" +display_name = "Gert Pellin" +sync_github_name = false diff --git a/crates/collab-net/src/barred_tests.rs b/crates/collab-net/src/barred_tests.rs index c9355a5f..7942e766 100644 --- a/crates/collab-net/src/barred_tests.rs +++ b/crates/collab-net/src/barred_tests.rs @@ -51,3 +51,47 @@ fn a_closed_file_forgets_whom_it_barred() { assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it"); assert!(hub.barred.contains_key("other"), "another file's stay"); } + +#[test] +fn a_kicked_connection_with_a_full_queue_is_shut() { + use std::io::Read; + use std::net::TcpListener; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::mpsc::sync_channel; + use std::time::Duration; + + use super::{Conn, Kind, Line, Out}; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_side, _) = listener.accept().unwrap(); + client.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + // Stands in for the writer thread's handle: it keeps the socket open after the Conn is gone. + let _writer = server_side.try_clone().unwrap(); + let (tx, _rx) = sync_channel(2); + while tx.try_send(Out::Bytes(Default::default(), None)).is_ok() {} + let out = Line { + tx, + queued: Arc::default(), + block: Arc::new(AtomicUsize::new(0)), + all: Arc::new(AtomicUsize::new(0)), + max: 1 << 20, + max_block: usize::MAX, + max_all: usize::MAX, + }; + let ip = IpAddr::V4(Ipv4Addr::LOCALHOST); + let mut hub = Hub { + in_flight: InFlight(Default::default()), + backend: Closes, + rooms: HashMap::new(), + conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing, ip })]), + local: None, + outbox: Vec::new(), + barred: HashMap::new(), + }; + hub.kick(&[1], "kicked"); + assert!(hub.conns.is_empty()); + let mut buf = [0u8; 1]; + assert_eq!(client.read(&mut buf).ok(), Some(0), "the kicked socket must be shut (client sees EOF)"); +} diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 5cf3e519..c2202919 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -402,6 +402,8 @@ enum HubIn { sock: TcpStream, /// Its size (in flight until handled). bytes: usize, + /// Told when the hub takes the connection in (dropped unsent when it doesn't). + admitted: SyncSender<()>, }, Msg { conn: u64, @@ -811,17 +813,22 @@ fn encode(msg: &ServerMsg) -> Option { /// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps). fn refuse(out: &Line, why: &str) { + refused(out, why); +} + +/// [`refuse`], and whether the close was queued (it isn't when the queue is full). +fn refused(out: &Line, why: &str) -> bool { if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) { let _ = out.tx.try_send(Out::Bytes(b, None)); } - let _ = out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))); + out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok() } /// What the server tells a client when it is too busy to take more from it. const BUSY: &str = "the server is busy: try again in a moment"; -/// Read the next binary message (skipping pings) and its size, or why the connection should end. -fn next_message(ws: &mut WebSocket, max: usize) -> Result, String> { +/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end. +fn next_message(ws: &mut WebSocket, max: usize, deadline: Option) -> Result, String> { loop { return match ws.read() { Ok(Message::Binary(b)) => { @@ -830,6 +837,13 @@ fn next_message(ws: &mut WebSocket, max: usize) -> Result(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string()) } Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()), + // Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and + // a message trickled in is cut off by it (`Duplex::read_until`). + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_)) + if deadline.is_some_and(|d| Instant::now() >= d) => + { + Err("no hello in time".into()) + } Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue, Ok(Message::Close(_)) => Ok(None), Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)), @@ -851,6 +865,7 @@ fn too_large(max: usize) -> String { /// One connection's reader (the writer runs on its own thread). fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, open: Open, shared: &Shared) { let _ = stream.set_nodelay(true); + let hello_deadline = Instant::now() + limits.hello_timeout; if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { return; } @@ -859,7 +874,9 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit } let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); - let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return }; + let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return }; + // The handshake and the hello must be in by the deadline, however slowly they trickle in. + rd.read_until(Some(hello_deadline)); let cfg = config(limits.max_client_message); let mut forwarded = None; // The handshake callback's type is tungstenite's (its error is an HTTP response). @@ -923,7 +940,10 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit log::info!("event=refused ip={ip} reason=connections_per_address"); end(Some("too many connections from your address: close some and try again")); } else { - match next_message(&mut ws, limits.max_hello) { + let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline)); + // In time or not, the hello's deadline is over: what's read next has timeouts of its own. + ws.get_mut().read_until(None); + match hello { Ok(Some(( first @ (ClientMsg::Hello(_) | ClientMsg::Manage(_) @@ -946,9 +966,13 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) { end(Some(BUSY)); } else { - ws.set_config(|c| *c = config(max)); - let opened = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes }).is_ok()); - if opened && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { + let (admitted, verdict) = sync_channel(1); + let opened = + sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok()); + // Nothing more is read until the hub took the connection in (its token checked): + // only then may it send messages of the full size. + if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { + ws.set_config(|c| *c = config(max)); read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end); let _ = hub.send(HubIn::Gone { conn }); } @@ -962,6 +986,7 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit drop(counted); // The block's counters go with the last handle on them (the table forgets it then). drop(block); + ws.get_mut().read_until(None); linger(&mut ws); drop(out); // The writer closes the socket once it has sent what's queued (a refusal, a close). @@ -981,7 +1006,7 @@ fn read_loop( let mut bucket = Bucket::new(limits.rate, limits.burst); let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST); loop { - match next_message(ws, max) { + match next_message(ws, max, None) { Ok(Some((msg, bytes))) => { let now = Instant::now(); if !bucket.take(now) { @@ -1074,6 +1099,8 @@ struct Pending { out: Line, sock: TcpStream, ip: IpAddr, + /// Told when it is taken in. + admitted: SyncSender<()>, } /// A connection the hub took in. @@ -1195,7 +1222,7 @@ impl Hub { fn handle(&mut self, m: HubIn) { match m { - HubIn::Open { conn, ip, first, out, sock, bytes } => { + HubIn::Open { conn, ip, first, out, sock, bytes, admitted } => { self.in_flight.give(bytes); // The operator (with the server key) gets in from anywhere; everyone else is // checked (blocked addresses, connection rates). @@ -1204,7 +1231,7 @@ impl Hub { log::info!("event=refused ip={ip} reason={why:?}"); return refuse(&out, &why); } - let c = Pending { out, sock, ip }; + let c = Pending { out, sock, ip, admitted }; match first { ClientMsg::Hello(hello) => self.join(conn, hello, c), ClientMsg::Manage(m) => self.manage(conn, &m, c), @@ -1399,6 +1426,7 @@ impl Hub { if let Some(c) = old.and_then(|old| self.conns.remove(&old)) { shutdown(&c.sock); } + let _ = c.admitted.try_send(()); self.conns .insert(conn, Conn { out: c.out, sock: c.sock, kind: Kind::Joined { file: file.clone(), id, link: admission.link }, ip: c.ip }); self.queue(&file, more); @@ -1429,7 +1457,9 @@ impl Hub { barred.insert(c.ip); } log::info!("event=removed file={file} client={id} ip={}", c.ip); - let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())); + if c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())).is_err() { + shutdown(&c.sock); + } self.gone(conn); Ok(()) } @@ -1444,6 +1474,7 @@ impl Hub { /// Take in connection `c` as `kind` and send it `first`. fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) { + let _ = c.admitted.try_send(()); self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip }); self.answer(conn, first); } @@ -1543,8 +1574,10 @@ impl Hub { // People in the file are told the session is over for them, as when a host stops (they // keep their copy; nothing retries); a library request is refused. for conn in &people { - if let Some(c) = self.conns.get(conn) { - let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into())); + if let Some(c) = self.conns.get(conn) + && c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into())).is_err() + { + shutdown(&c.sock); } self.gone(*conn); } @@ -1593,8 +1626,10 @@ impl Hub { /// Refuse connections with `why` and drop them at once (nothing more they send counts). fn kick(&mut self, conns: &[u64], why: &str) { for conn in conns { - if let Some(c) = self.conns.get(conn) { - refuse(&c.out, why); + if let Some(c) = self.conns.get(conn) + && !refused(&c.out, why) + { + shutdown(&c.sock); } self.gone(*conn); } @@ -1747,3 +1782,7 @@ mod queue_tests; #[cfg(test)] #[path = "barred_tests.rs"] mod barred_tests; + +#[cfg(test)] +#[path = "preauth_tests.rs"] +mod preauth_tests; diff --git a/crates/collab-net/src/inbox.rs b/crates/collab-net/src/inbox.rs index fb505a0a..1ec00e67 100644 --- a/crates/collab-net/src/inbox.rs +++ b/crates/collab-net/src/inbox.rs @@ -11,11 +11,18 @@ use figcraft_engine::live::{Event, Presence}; pub type Waker = Arc; /// Most discrete events kept between two drains (joins, leaves, status); the oldest go first. +/// Document sync events don't count and are never dropped: the sync client treats a missing +/// batch as a fatal sequence gap, so losing one would end the session. const MAX_EVENTS: usize = 10_000; #[derive(Default)] struct State { - events: VecDeque, + /// Arrival number of the next event, so [`Inbox::drain`] can merge the two queues in order. + next: u64, + /// [`Event::Sync`] events, never dropped. + syncs: VecDeque<(u64, Event)>, + /// Every other event, capped at [`MAX_EVENTS`]. + others: VecDeque<(u64, Event)>, presence: BTreeMap, } @@ -43,11 +50,17 @@ impl Inbox { if let Event::PeerLeft(id) = &e { s.presence.remove(id); } - if s.events.len() >= MAX_EVENTS { - log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest"); - s.events.pop_front(); + let n = s.next; + s.next += 1; + if matches!(e, Event::Sync(_)) { + s.syncs.push_back((n, e)); + return; } - s.events.push_back(e); + if s.others.len() >= MAX_EVENTS { + log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest"); + s.others.pop_front(); + } + s.others.push_back((n, e)); }); } @@ -62,8 +75,70 @@ impl Inbox { /// peer. pub fn drain(&self) -> Vec { let mut s = self.state.lock().unwrap_or_else(PoisonError::into_inner); - let mut out: Vec = s.events.drain(..).collect(); + let mut syncs = std::mem::take(&mut s.syncs).into_iter().peekable(); + let mut others = std::mem::take(&mut s.others).into_iter().peekable(); + let mut out = Vec::with_capacity(syncs.len() + others.len() + s.presence.len()); + loop { + let sync_first = match (syncs.peek(), others.peek()) { + (Some((a, _)), Some((b, _))) => a < b, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => break, + }; + let next = if sync_first { syncs.next() } else { others.next() }; + out.extend(next.map(|(_, e)| e)); + } out.extend(std::mem::take(&mut s.presence).into_iter().map(|(id, p)| Event::Presence(id, p))); out } } + +#[cfg(test)] +mod tests { + use super::*; + use figcraft_collab::message::ServerMsg; + + fn reject(batch_id: u64) -> Event { + Event::Sync(ServerMsg::Reject { batch_id, reason: String::new(), fixes: Vec::new() }) + } + + fn sync_ids(events: &[Event]) -> Vec { + events + .iter() + .filter_map(|e| match e { + Event::Sync(ServerMsg::Reject { batch_id, .. }) => Some(*batch_id), + _ => None, + }) + .collect() + } + + #[test] + fn a_full_inbox_never_drops_sync_events() { + let inbox = Inbox::new(Arc::new(|| {})); + inbox.push(reject(1)); + for _ in 0..MAX_EVENTS { + inbox.push(Event::PeerLeft(7)); + } + inbox.push(reject(2)); + for _ in 0..MAX_EVENTS { + inbox.push(Event::PeerLeft(7)); + } + let out = inbox.drain(); + assert_eq!(sync_ids(&out), vec![1, 2]); + assert!(out.len() <= MAX_EVENTS + 2, "{} events kept", out.len()); + } + + #[test] + fn sync_events_past_the_cap_are_all_kept_in_order() { + let inbox = Inbox::new(Arc::new(|| {})); + let n = MAX_EVENTS as u64 + 5; + for id in 0..n { + inbox.push(reject(id)); + } + inbox.push(Event::PeerLeft(7)); + let out = inbox.drain(); + let ids = sync_ids(&out); + assert_eq!(ids.len() as u64, n, "Sync events dropped"); + assert!(ids.iter().copied().eq(0..n), "Sync events out of order"); + } +} diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index a043d8c7..d5a38d9d 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -2,7 +2,7 @@ //! refusals, presence relay and throttling, leaving and dropping, limits, malformed frames, and //! reconnecting. Every test stops its host and closes its sockets. -use std::io::Read; +use std::io::{Read, Write}; use std::net::{SocketAddr, TcpStream}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -670,3 +670,49 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { assert!(!welcomed(&gp.events)); host.stop(); } + +/// How long a connection that never says hello stays open while `poke` sends it something every +/// 300 ms (told whether it's the first time); `None`: still open after 6 s. +fn pre_hello_lifetime(mut poke: impl FnMut(&mut Raw, bool) -> bool) -> Option { + let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default()); + let addr: SocketAddr = invite.server.host.parse().unwrap(); + let mut r = Raw::connect(addr); + let t0 = Instant::now(); + let (mut closed, mut first) = (None, true); + while t0.elapsed() < Duration::from_secs(6) { + if !poke(&mut r, std::mem::take(&mut first)) { + closed = Some(t0.elapsed()); + break; + } + std::thread::sleep(Duration::from_millis(300)); + // Drain what the host sent back (pongs, the close); an error means it hung up. + r.0.get_mut().set_read_timeout(Some(Duration::from_millis(10))).unwrap(); + match r.0.read() { + Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => { + closed = Some(t0.elapsed()); + break; + } + Err(tungstenite::Error::Io(e)) if !matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => { + closed = Some(t0.elapsed()); + break; + } + _ => {} + } + } + host.stop(); + closed +} + +#[test] +fn pings_do_not_keep_a_connection_without_a_hello_alive() { + let closed = pre_hello_lifetime(|r, _| r.0.send(Message::Ping(Bytes::new())).is_ok() && r.0.flush().is_ok()); + assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "pinged pre-hello connection survived: {closed:?}"); +} + +#[test] +fn a_hello_trickled_in_fragments_does_not_outlast_the_deadline() { + // A binary message begun and never finished: one masked byte per frame, each in time for the + // read timeout. + let closed = pre_hello_lifetime(|r, first| r.0.get_mut().write_all(&[if first { 0x02 } else { 0x00 }, 0x81, 0, 0, 0, 0, b'x']).is_ok()); + assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "trickled pre-hello connection survived: {closed:?}"); +} diff --git a/crates/collab-net/src/manage.rs b/crates/collab-net/src/manage.rs index 6e995bdb..2f0683c7 100644 --- a/crates/collab-net/src/manage.rs +++ b/crates/collab-net/src/manage.rs @@ -206,9 +206,15 @@ fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option< }; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Whether `close` returns within 3 s against a server that never answers the close and does + /// `serve` instead, with reads waiting up to `idle`. + fn closes_in_time(idle: Duration, serve: impl FnOnce(WebSocket) + Send + 'static) -> bool { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (s, _) = listener.accept().unwrap(); + if let Ok(ws) = tungstenite::accept(s) { + serve(ws); + } + }); + let limits = NetLimits { idle_timeout: idle, ..NetLimits::default() }; + let server = Endpoint { host: addr.to_string(), path: "/".into(), secure: false }; + let ws = connect(&server, &limits, Duration::from_secs(2)).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + close(ws); + let _ = tx.send(()); + }); + rx.recv_timeout(Duration::from_secs(3)).is_ok() + } + + /// A server that keeps pinging and never answers the close must not pin `close` forever. + #[test] + fn close_returns_against_a_server_that_keeps_pinging() { + let pinging = closes_in_time(Duration::from_millis(300), |mut ws| { + while ws.send(Message::Ping(Bytes::new())).is_ok() { + std::thread::sleep(Duration::from_millis(10)); + } + }); + assert!(pinging, "close() kept draining a pinging server"); + } + + /// Nor one that goes quiet, however long the idle timeout. + #[test] + fn close_returns_against_a_silent_server() { + let silent = closes_in_time(Duration::from_secs(10), |ws| { + std::thread::sleep(Duration::from_secs(5)); + drop(ws); + }); + assert!(silent, "close() waited out the idle timeout"); + } +} diff --git a/crates/collab-net/src/net.rs b/crates/collab-net/src/net.rs index 8c841e9c..c6628662 100644 --- a/crates/collab-net/src/net.rs +++ b/crates/collab-net/src/net.rs @@ -33,14 +33,16 @@ pub struct Duplex { tls: Option, /// What the bytes read count against (a server's reader), if anything. meter: Option, + /// When reading must be over, however slowly the bytes come ([`Duplex::read_until`]). + deadline: Option, } impl Duplex { /// Two handles on `stream` (for the reader and the writer) and one to shut it down with. pub fn split(stream: TcpStream, tls: Option) -> io::Result<(Duplex, Duplex, TcpStream)> { let write = Arc::new(Mutex::new(stream.try_clone()?)); - let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None }; - let b = Duplex { read: stream.try_clone()?, write, tls, meter: None }; + let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None, deadline: None }; + let b = Duplex { read: stream.try_clone()?, write, tls, meter: None, deadline: None }; Ok((a, b, stream)) } @@ -49,6 +51,12 @@ impl Duplex { self.meter = Some(meter); } + /// Reads on this handle fail once `deadline` passes, even when each one gets a few bytes in + /// time (a message trickled in can't outlast it). `None`: only the read timeout applies. + pub fn read_until(&mut self, deadline: Option) { + self.deadline = deadline; + } + /// What was read so far became a message (handed on): it no longer counts as being read. pub fn read_done(&mut self) { if let Some(m) = self.meter.as_mut() { @@ -187,6 +195,13 @@ impl Duplex { impl Read for Duplex { fn read(&mut self, buf: &mut [u8]) -> io::Result { + if let Some(d) = self.deadline { + let left = d.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(io::ErrorKind::TimedOut.into()); + } + self.read.set_read_timeout(Some(left))?; + } let n = match &self.tls { Some(t) => tls::read(t, &mut self.read, &self.write, buf), None => self.read.read(buf), diff --git a/crates/collab-net/src/preauth_tests.rs b/crates/collab-net/src/preauth_tests.rs new file mode 100644 index 00000000..5ae91665 --- /dev/null +++ b/crates/collab-net/src/preauth_tests.rs @@ -0,0 +1,72 @@ +//! A connection whose hello the backend hasn't admitted yet is held to the hello's size limit: it +//! can't have a message of the full size read while its token is unchecked. + +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::mpsc::{Receiver, channel}; +use std::time::{Duration, Instant}; + +use figcraft_collab::message::{self, ClientMsg, ServerMsg, Signal}; +use figcraft_collab::{Client, Server}; +use tungstenite::{Bytes, Message}; + +use super::{Admission, Backend, Closing, Hosting}; +use crate::net::NetLimits; + +/// A backend that refuses every hello, once it is told to. +struct Slow(Receiver<()>); + +impl Backend for Slow { + fn admit(&mut self, _file: &str, _token: &str) -> Result { + let _ = self.0.recv(); + Err("wrong token".into()) + } + + fn open(&mut self, _file: &str) -> Result { + Err("no".into()) + } + + fn closing(&self) -> Closing { + Closing::Ended + } +} + +fn connect(addr: SocketAddr) -> tungstenite::WebSocket { + let s = TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0 +} + +#[test] +fn an_unadmitted_connection_cant_send_a_large_message() { + let limits = NetLimits { hello_timeout: Duration::from_secs(5), ..NetLimits::default() }; + let (release, wait) = channel(); + let mut hosting = Hosting::serve(TcpListener::bind("127.0.0.1:0").unwrap(), Slow(wait), limits).unwrap(); + let addr = hosting.addr(); + let sender = std::thread::spawn(move || { + let mut ws = connect(addr); + let hello = message::encode(&Client::hello("0123456789abcdef", "bad", "x", "")).unwrap(); + ws.send(Message::Binary(Bytes::from(hello))).unwrap(); + let big = ClientMsg::Signal(Signal { kind: Signal::CHAT.into(), text: "x".repeat(1 << 20) }); + let _ = ws.send(Message::Binary(Bytes::from(message::encode(&big).unwrap()))); + let mut refused = None; + while let Ok(m) = ws.read() { + if let Message::Binary(b) = m + && let Ok(ServerMsg::Refused { reason }) = message::decode(&b, 64 << 20) + { + refused = Some(reason); + } + } + refused + }); + let mut peak = 0; + let until = Instant::now() + Duration::from_secs(2); + while Instant::now() < until { + peak = peak.max(hosting.in_flight_bytes()); + std::thread::sleep(Duration::from_millis(2)); + } + release.send(()).unwrap(); + let refused = sender.join().unwrap(); + hosting.stop(); + assert!(peak <= limits.max_hello, "a message of {peak} bytes was read before the token was checked"); + assert_eq!(refused.as_deref(), Some("wrong token")); +} diff --git a/crates/collab/src/board_sync_tests.rs b/crates/collab/src/board_sync_tests.rs new file mode 100644 index 00000000..3dbff8bb --- /dev/null +++ b/crates/collab/src/board_sync_tests.rs @@ -0,0 +1,45 @@ +//! Boards sync: the file kind reaches joiners in their snapshot, and a layer's board metadata (a +//! connector's ends, a sticky's author) syncs as one property, through both encodings. + +use figcraft_doc::{BoardItem, Connector, Document, End, FileKind, NodeKind, Routing, Side}; + +use crate::change::{Change, DocProp, Prop}; +use crate::replica::{Mode, Replica}; +use crate::{diff, message}; + +#[test] +fn a_boards_kind_and_its_objects_metadata_sync() { + let mut doc = Document::new("Board"); + doc.kind = FileKind::Board; + let page = doc.first_page().unwrap(); + let a = doc.create(page, NodeKind::Frame, "Sticky", 0.0, 0.0, 200.0, 200.0).unwrap(); + doc.node_mut(a).unwrap().board = Some(BoardItem::Sticky { author: "Ada".into() }); + let host = Replica::new(doc); + // A joiner's copy, built from the snapshot, is a board with the sticky's metadata. + let snap = host.snapshot().unwrap(); + assert!(snap.iter().any(|c| matches!(c, Change::SetDoc { prop: DocProp::Kind, .. }))); + let joiner = Replica::from_snapshot(&snap).unwrap(); + assert_eq!(joiner.document().kind, FileKind::Board); + assert_eq!(joiner.document().node(a).unwrap().board, host.document().node(a).unwrap().board); + // Re-attaching a connector's end is one property write, the same after the wire. + let mut edited = host.document().clone(); + let c = edited.create(page, NodeKind::Vector { network: Default::default() }, "Connector", 0.0, 0.0, 1.0, 1.0).unwrap(); + edited.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector { + from: End::Node { node: a, side: Side::Auto }, + to: End::Point { x: 5.0, y: 5.0 }, + routing: Routing::Elbow, + })); + let mut r = host.clone(); + r.apply(&diff(&host, &edited).unwrap().changes, Mode::Strict).unwrap(); + let mut moved = r.document().clone(); + moved.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector { + from: End::Node { node: a, side: Side::Top }, + to: End::Point { x: 9.0, y: 1.0 }, + routing: Routing::Straight, + })); + let d = diff(&r, &moved).unwrap(); + assert!(matches!(d.changes.as_slice(), [Change::Set { prop: Prop::Board, .. }]), "{:?}", d.changes); + let wire: Vec = message::decode(&message::encode(&d.changes).unwrap(), usize::MAX).unwrap(); + r.apply(&wire, Mode::Strict).unwrap(); + assert_eq!(r.document(), &moved); +} diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 0608ba67..57bb9cf2 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -181,6 +181,7 @@ node_props! { Annotations => annotations, CodeLinks => code_links, DevResources => dev_resources, + Board => board, } /// A document-level property. Lists of entries with ids (styles, comments, variable collections, @@ -219,6 +220,8 @@ pub enum DocProp { Variables, /// Variable ids in order. VariableOrder, + /// A design file or a board. + Kind, } /// A document-level entry, by id. diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index 535c333e..9b35e05d 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -213,6 +213,7 @@ doc_scalars! { ImagePresets => image_presets, AnnotationCategories => annotation_categories, Libraries => libraries, + Kind => kind, } /// 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 2c693bc4..fa271f35 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -123,6 +123,8 @@ impl Recompute for NoRecompute { fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet) {} } +#[cfg(test)] +mod board_sync_tests; #[cfg(test)] mod client_tests; #[cfg(test)] diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index 734759ee..250e8ab0 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -103,7 +103,7 @@ impl BlendMode { /// The mode named by its label or CSS keyword, ignoring case, spaces and dashes. Labels come /// first: "normal" is Normal, not pass-through (whose CSS keyword is also `normal`). pub fn from_name(s: &str) -> Option { - let key: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + let key: String = s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); Self::ALL .into_iter() .find(|m| m.label().replace(' ', "").to_ascii_lowercase() == key) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index fdad0fa7..1fff7667 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -549,7 +549,7 @@ pub enum PaintKind { } /// One fill or stroke paint. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Paint { #[serde(flatten)] pub kind: PaintKind, @@ -559,19 +559,41 @@ pub struct Paint { pub visible: bool, #[serde(default)] pub blend: BlendMode, + /// The part of the opacity that is the alpha of a typed solid colour (see [`Paint::solid`]), + /// the rest being an opacity set on its own: a colour variable bound to the paint replaces + /// this part only. 1: all of it is the paint's own. + #[serde(default = "one", rename = "colorAlpha", skip_serializing_if = "is_one")] + pub color_alpha: f32, +} + +/// Paints compare by what they paint; where the opacity came from doesn't matter. +impl PartialEq for Paint { + fn eq(&self, other: &Self) -> bool { + self.kind == other.kind && self.opacity == other.opacity && self.visible == other.visible && self.blend == other.blend + } } fn yes() -> bool { true } +fn is_one(x: &f32) -> bool { + *x == 1.0 +} + impl Paint { pub fn solid(color: Color) -> Self { - Self { kind: PaintKind::Solid { color: color.with_alpha(1.0) }, opacity: color.a, visible: true, blend: BlendMode::Normal } + Self { + kind: PaintKind::Solid { color: color.with_alpha(1.0) }, + opacity: color.a, + visible: true, + blend: BlendMode::Normal, + color_alpha: color.a, + } } pub fn gradient(g: Gradient) -> Self { - Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal } + Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 } } pub fn image(hash: impl Into, mode: ImageScaleMode) -> Self { @@ -587,12 +609,13 @@ impl Paint { opacity: 1.0, visible: true, blend: BlendMode::Normal, + color_alpha: 1.0, } } /// Layer `source` (a node id) tiled at its own size. pub fn pattern(source: u64) -> Self { - Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal } + Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 } } /// The solid colour (with the paint opacity folded into alpha), if solid. diff --git a/crates/doc/src/board.rs b/crates/doc/src/board.rs new file mode 100644 index 00000000..28ed0c1d --- /dev/null +++ b/crates/doc/src/board.rs @@ -0,0 +1,433 @@ +//! Boards: a file kind for whiteboarding, and the small piece of metadata board objects carry. +//! +//! Board objects are ordinary nodes (frames, vectors, text) with a [`BoardItem`] on them, so +//! rendering, hit testing, undo, saving, export, copy and paste and live sessions treat them like +//! any other layer. The metadata says what the object is for: which text a sticky note fits, what +//! a connector joins, who placed a stamp. + +use figcraft_color::Color; +use figcraft_geom::kurbo::{Ellipse, RoundedRect}; +use figcraft_geom::{BezPath, Point, Rect, Shape}; +use serde::{Deserialize, Serialize}; + +use crate::NodeId; + +/// What a file is for: designing screens, or a board to think together on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FileKind { + #[default] + Design, + Board, +} + +impl FileKind { + pub fn is_design(&self) -> bool { + *self == FileKind::Design + } + pub fn id(self) -> &'static str { + match self { + FileKind::Design => "design", + FileKind::Board => "board", + } + } +} + +/// What a board object is. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum BoardItem { + /// A sticky note: a frame whose first text layer is the note (its size shrinks as the note + /// grows) and, below it, who wrote it. + Sticky { + #[serde(default, skip_serializing_if = "String::is_empty")] + author: String, + }, + /// A shape with text: a frame holding the outline (a vector) and a centred text. + Shape { shape: BoardShape }, + /// A line between two objects or points (a vector, re-routed when they move). + Connector(Connector), + /// The label in the middle of connector `connector` (a frame holding its text). + Label { connector: NodeId }, + /// A stamp, and who placed it. + Stamp { + stamp: Stamp, + #[serde(default, skip_serializing_if = "String::is_empty")] + author: String, + }, + /// A marker or highlighter stroke (what the eraser takes). + Ink, +} + +impl BoardItem { + /// Objects selected and moved as a whole: a click on their text or outline picks them, and + /// they never take other layers in. + pub fn is_atom(&self) -> bool { + matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::Stamp { .. }) + } + + /// Objects whose first text layer is their text (a double-click or Enter edits it). + pub fn has_text(&self) -> bool { + matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. }) + } + + /// Who made it (stickies and stamps; empty when unknown). + pub fn author(&self) -> &str { + match self { + BoardItem::Sticky { author } | BoardItem::Stamp { author, .. } => author, + _ => "", + } + } + + /// Point the layers this object refers to (a connector's ends, a label's connector) at their + /// copies in `map`; the others stay. + pub fn remap(&mut self, map: &std::collections::BTreeMap) { + match self { + BoardItem::Connector(c) => { + for end in [&mut c.from, &mut c.to] { + if let End::Node { node, .. } = end + && let Some(m) = map.get(node) + { + *node = *m; + } + } + } + BoardItem::Label { connector } => { + if let Some(m) = map.get(connector) { + *connector = *m; + } + } + _ => {} + } + } + + pub fn kind_id(&self) -> &'static str { + match self { + BoardItem::Sticky { .. } => "sticky", + BoardItem::Shape { .. } => "shape", + BoardItem::Connector(_) => "connector", + BoardItem::Label { .. } => "label", + BoardItem::Stamp { .. } => "stamp", + BoardItem::Ink => "ink", + } + } +} + +/// The colours stickies, shapes and connectors come in: soft tints that dark text reads on. +pub const BOARD_COLORS: [(&str, Color); 8] = [ + ("yellow", Color::from_rgb8_const(0xFF, 0xE7, 0x8A)), + ("orange", Color::from_rgb8_const(0xFF, 0xC9, 0x94)), + ("red", Color::from_rgb8_const(0xFF, 0xAE, 0xA6)), + ("pink", Color::from_rgb8_const(0xF7, 0xB9, 0xDA)), + ("violet", Color::from_rgb8_const(0xD4, 0xC2, 0xFC)), + ("blue", Color::from_rgb8_const(0xAC, 0xD3, 0xFA)), + ("green", Color::from_rgb8_const(0xC2, 0xE8, 0xA6)), + ("gray", Color::from_rgb8_const(0xE3, 0xE1, 0xDD)), +]; + +/// A board colour by name ("yellow") or as hex ("#FFE78A"). +pub fn board_color(s: &str) -> Option { + let s = s.trim(); + BOARD_COLORS.iter().find(|(n, _)| n.eq_ignore_ascii_case(s)).map(|(_, c)| *c).or_else(|| Color::from_hex(s)) +} + +/// The page colour boards start with: a warm light grey the colours stand out on. +pub const BOARD_PAGE: Color = Color::from_rgb8_const(0xF4, 0xF3, 0xF0); + +/// The outlines shapes with text come in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BoardShape { + #[default] + Rectangle, + RoundedRectangle, + Ellipse, + Diamond, + Triangle, + SpeechBubble, + Chevron, +} + +impl BoardShape { + pub const ALL: [BoardShape; 7] = [ + BoardShape::Rectangle, + BoardShape::RoundedRectangle, + BoardShape::Ellipse, + BoardShape::Diamond, + BoardShape::Triangle, + BoardShape::SpeechBubble, + BoardShape::Chevron, + ]; + + pub fn id(self) -> &'static str { + match self { + BoardShape::Rectangle => "rectangle", + BoardShape::RoundedRectangle => "roundedRectangle", + BoardShape::Ellipse => "ellipse", + BoardShape::Diamond => "diamond", + BoardShape::Triangle => "triangle", + BoardShape::SpeechBubble => "speechBubble", + BoardShape::Chevron => "chevron", + } + } + + pub fn from_id(s: &str) -> Option { + BoardShape::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s)) + } + + pub fn label(self) -> &'static str { + match self { + BoardShape::Rectangle => "Rectangle", + BoardShape::RoundedRectangle => "Rounded rectangle", + BoardShape::Ellipse => "Ellipse", + BoardShape::Diamond => "Diamond", + BoardShape::Triangle => "Triangle", + BoardShape::SpeechBubble => "Speech bubble", + BoardShape::Chevron => "Chevron", + } + } + + /// The outline in a `w` × `h` box at the origin. + pub fn path(self, w: f64, h: f64) -> BezPath { + let poly = |pts: &[(f64, f64)]| { + let mut p = BezPath::new(); + for (i, (x, y)) in pts.iter().enumerate() { + let q = Point::new(x * w, y * h); + if i == 0 { p.move_to(q) } else { p.line_to(q) } + } + p.close_path(); + p + }; + let r = w.min(h); + match self { + BoardShape::Rectangle => Rect::new(0.0, 0.0, w, h).to_path(0.1), + BoardShape::RoundedRectangle => RoundedRect::new(0.0, 0.0, w, h, r * 0.16).to_path(0.1), + BoardShape::Ellipse => Ellipse::from_rect(Rect::new(0.0, 0.0, w, h)).to_path(0.1), + BoardShape::Diamond => poly(&[(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)]), + BoardShape::Triangle => poly(&[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)]), + BoardShape::Chevron => poly(&[(0.0, 0.0), (0.72, 0.0), (1.0, 0.5), (0.72, 1.0), (0.0, 1.0), (0.28, 0.5)]), + BoardShape::SpeechBubble => { + // A rounded body over the bottom fifth, with a tail down to the left. + let (b, c) = (h * 0.8, r * 0.14); + let mut p = BezPath::new(); + p.move_to((c, 0.0)); + p.line_to((w - c, 0.0)); + p.quad_to((w, 0.0), (w, c)); + p.line_to((w, b - c)); + p.quad_to((w, b), (w - c, b)); + p.line_to((w * 0.42, b)); + p.line_to((w * 0.18, h)); + p.line_to((w * 0.24, b)); + p.line_to((c, b)); + p.quad_to((0.0, b), (0.0, b - c)); + p.line_to((0.0, c)); + p.quad_to((0.0, 0.0), (c, 0.0)); + p.close_path(); + p + } + } + } + + /// Where the text goes, as insets of the box (top, right, bottom, left, in fractions of its + /// size): inside the outline's widest part. + pub fn text_insets(self) -> [f64; 4] { + match self { + BoardShape::Rectangle | BoardShape::RoundedRectangle => [0.08, 0.08, 0.08, 0.08], + BoardShape::Ellipse => [0.16, 0.16, 0.16, 0.16], + BoardShape::Diamond => [0.25, 0.25, 0.25, 0.25], + BoardShape::Triangle => [0.45, 0.22, 0.06, 0.22], + BoardShape::SpeechBubble => [0.08, 0.08, 0.28, 0.08], + BoardShape::Chevron => [0.12, 0.3, 0.12, 0.3], + } + } +} + +/// The stamps: round badges with a mark. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Stamp { + #[default] + ThumbsUp, + Heart, + PlusOne, + Star, + Question, + Check, +} + +/// One part of a stamp's mark: SVG path data on the badge's 40-unit box, filled or stroked. +pub struct MarkPart { + pub d: &'static str, + pub filled: bool, +} + +/// A stamp badge's size. +pub const STAMP_SIZE: f64 = 40.0; + +impl Stamp { + pub const ALL: [Stamp; 6] = [Stamp::ThumbsUp, Stamp::Heart, Stamp::PlusOne, Stamp::Star, Stamp::Question, Stamp::Check]; + + pub fn id(self) -> &'static str { + match self { + Stamp::ThumbsUp => "thumbsUp", + Stamp::Heart => "heart", + Stamp::PlusOne => "plusOne", + Stamp::Star => "star", + Stamp::Question => "question", + Stamp::Check => "check", + } + } + + pub fn from_id(s: &str) -> Option { + Stamp::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s)) + } + + pub fn label(self) -> &'static str { + match self { + Stamp::ThumbsUp => "Thumbs up", + Stamp::Heart => "Heart", + Stamp::PlusOne => "Plus one", + Stamp::Star => "Star", + Stamp::Question => "Question", + Stamp::Check => "Check", + } + } + + /// The badge's colour (the mark is white on it). + pub fn color(self) -> Color { + match self { + Stamp::ThumbsUp => Color::from_rgb8_const(0x3B, 0x82, 0xE8), + Stamp::Heart => Color::from_rgb8_const(0xE5, 0x48, 0x5F), + Stamp::PlusOne => Color::from_rgb8_const(0x2E, 0xA0, 0x63), + Stamp::Star => Color::from_rgb8_const(0xEE, 0x9B, 0x1A), + Stamp::Question => Color::from_rgb8_const(0x84, 0x5E, 0xD8), + Stamp::Check => Color::from_rgb8_const(0x14, 0x96, 0x8C), + } + } + + /// The mark, drawn for FigCraft from lines, arcs and simple outlines. + pub fn mark(self) -> &'static [MarkPart] { + match self { + // A cuff and a raised hand with its thumb up. + Stamp::ThumbsUp => &[ + MarkPart { d: "M10.5 19 H15 V29 H10.5 Z", filled: true }, + MarkPart { + d: "M16.5 19 L20.6 11.4 C21.6 9.8 24.6 10.6 24.2 13 L23.4 17 H28.2 C29.8 17 30.9 18.5 30.4 20 L28.4 27.3 C28 28.4 27.1 29 25.9 29 H16.5 Z", + filled: true, + }, + ], + // Two round lobes meeting in a point. + Stamp::Heart => &[MarkPart { + d: "M20 29.5 C12.5 24.5 9.5 20.6 10.4 16.4 C11.3 12.2 16.8 11.2 20 15.4 C23.2 11.2 28.7 12.2 29.6 16.4 C30.5 20.6 27.5 24.5 20 29.5 Z", + filled: true, + }], + Stamp::PlusOne => &[MarkPart { d: "M10 20 H20 M15 15 V25", filled: false }, MarkPart { d: "M24.5 15 L27.5 12.5 V27.5", filled: false }], + Stamp::Star => &[MarkPart { + d: "M20 10.5 L22.8 16.7 L29.5 17.4 L24.5 21.9 L25.9 28.6 L20 25.2 L14.1 28.6 L15.5 21.9 L10.5 17.4 L17.2 16.7 Z", + filled: true, + }], + Stamp::Question => &[ + MarkPart { d: "M15.6 16.2 C15.6 10.6 24.4 10.6 24.4 16 C24.4 19.6 20 19.9 20 23.6", filled: false }, + MarkPart { d: "M18.2 28 A1.8 1.8 0 1 0 21.8 28 A1.8 1.8 0 1 0 18.2 28 Z", filled: true }, + ], + Stamp::Check => &[MarkPart { d: "M12.5 20.5 L18 26 L28 14.5", filled: false }], + } + } +} + +/// How a connector runs between its ends. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Routing { + #[default] + Straight, + /// Right angles only: out of each end along its side's normal, turning once or twice. + Elbow, +} + +impl Routing { + pub fn id(self) -> &'static str { + match self { + Routing::Straight => "straight", + Routing::Elbow => "elbow", + } + } + pub fn from_id(s: &str) -> Option { + [Routing::Straight, Routing::Elbow].into_iter().find(|r| r.id().eq_ignore_ascii_case(s)) + } +} + +/// The side of an object a connector end is attached to (at its middle). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Side { + /// The side facing the other end. + #[default] + Auto, + Top, + Right, + Bottom, + Left, +} + +impl Side { + pub fn from_id(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "auto" => Side::Auto, + "top" => Side::Top, + "right" => Side::Right, + "bottom" => Side::Bottom, + "left" => Side::Left, + _ => return None, + }) + } + + /// The side's middle on `b`. + pub fn point(self, b: Rect) -> Point { + let c = b.center(); + match self { + Side::Top => Point::new(c.x, b.y0), + Side::Right => Point::new(b.x1, c.y), + Side::Bottom => Point::new(c.x, b.y1), + Side::Left => Point::new(b.x0, c.y), + Side::Auto => c, + } + } + + pub fn horizontal(self) -> bool { + matches!(self, Side::Left | Side::Right) + } +} + +/// One end of a connector: attached to a layer, or a free point on the page. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum End { + Node { + node: NodeId, + #[serde(default)] + side: Side, + }, + Point { + x: f64, + y: f64, + }, +} + +impl End { + pub fn node(&self) -> Option { + match self { + End::Node { node, .. } => Some(*node), + End::Point { .. } => None, + } + } +} + +/// What a connector joins and how it runs (its arrowheads are its stroke's caps). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Connector { + pub from: End, + pub to: End, + #[serde(default)] + pub routing: Routing, +} diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 2a7b42a6..b4f89dbc 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -72,7 +72,16 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { n.local_rect().inflate(tol_l, tol_l).contains(lp) } - _ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && path.winding(lp) != 0)), + _ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))), + } +} + +/// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of +/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it). +fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool { + match &n.kind { + NodeKind::Vector { network } => network.fill_contains(lp), + _ => path.winding(lp) != 0, } } @@ -255,6 +264,18 @@ pub fn select_target(doc: &Document, leaf: NodeId, context: &[NodeId], deep: boo if deep { return leaf; } + let target = level_target(doc, leaf, context); + // A board object is picked whole (a sticky, not its text), unless a layer inside it is selected. + let mut chain = doc.ancestors(target); + chain.reverse(); + chain.push(target); + chain + .into_iter() + .find(|c| doc.node(*c).is_some_and(Node::is_board_atom) && !context.iter().any(|s| s != c && doc.is_ancestor(*c, *s))) + .unwrap_or(target) +} + +fn level_target(doc: &Document, leaf: NodeId, context: &[NodeId]) -> NodeId { // Chain from the page's child down to the leaf. let mut chain: Vec = doc.ancestors(leaf); chain.reverse(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d131b2f0..d4c556b9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -8,6 +8,7 @@ //! live in higher crates. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod board; pub mod colors; pub mod comments; pub mod geometry; @@ -23,6 +24,7 @@ pub mod vector; pub mod vector_faces; pub mod vector_regions; +pub use board::*; pub use colors::*; pub use comments::*; pub use figcraft_color as color; @@ -157,6 +159,9 @@ pub struct ImageData { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { pub name: String, + /// A design file or a board (files from before boards are design files). + #[serde(default, skip_serializing_if = "FileKind::is_design")] + pub kind: FileKind, /// Page ids in order. pub pages: Vec, nodes: imbl::OrdMap>, @@ -221,6 +226,7 @@ impl Document { pub fn without_pages(name: impl Into) -> Self { Self { name: name.into(), + kind: FileKind::Design, pages: Vec::new(), nodes: imbl::OrdMap::new(), dirty: Dirty::default(), @@ -269,7 +275,16 @@ impl Document { /// A fresh unused id (in this document's client space during a collaboration session; see /// [`set_client_id`](Self::set_client_id)). pub fn alloc_id(&mut self) -> NodeId { - self.next_id.alloc() + // A file's saved `next_id` can be stale (behind ids already in use): skip taken ids. + let mut id = self.next_id.alloc(); + while self.nodes.contains_key(&id) { + let next = self.next_id.alloc(); + if next == id { + break; + } + id = next; + } + id } /// The id [`alloc_id`](Self::alloc_id) returns next. @@ -453,8 +468,9 @@ impl Document { return true; } guard += 1; - if guard > 10_000 { - return false; + // More hops than nodes means the parent chain loops; fail closed so callers reject the move. + if guard > self.nodes.len() { + return true; } cur = self.node(c).and_then(|n| n.parent); } @@ -821,6 +837,11 @@ impl Document { return Err(DocError::NotContainer(parent)); } let new_root = *map.get(&root).ok_or(DocError::NoNode(root))?; + // Never overwrite an existing node, or another copy, with a copy. + let mut copied = std::collections::BTreeSet::new(); + if let Some(taken) = nodes.iter().filter_map(|n| map.get(&n.id)).find(|i| self.nodes.contains_key(*i) || !copied.insert(**i)) { + return Err(DocError::Invalid(format!("node {taken} already exists"))); + } for n in nodes { let Some(new_id) = map.get(&n.id) else { continue }; let mut c = n.clone(); @@ -835,6 +856,10 @@ impl Document { { *component = *m; } + // A connector (or label) copied with what it joins joins the copies. + if let Some(b) = c.board.as_mut() { + b.remap(map); + } // A pattern tiling a layer of the copy tiles the copy's layer. for p in c.fills.iter_mut().chain(c.strokes.iter_mut()) { if let figcraft_color::PaintKind::Pattern(pt) = &mut p.kind @@ -984,39 +1009,46 @@ impl Document { /// Check structural invariants (used by tests and after loading untrusted files). pub fn validate(&self) -> Vec { + // Hostile files can break an invariant per child; keep only the first few reports. + const MAX_ISSUES: usize = 100; + fn note(issues: &mut Vec, msg: impl FnOnce() -> String) { + if issues.len() < MAX_ISSUES { + issues.push(msg()); + } + } let mut issues = Vec::new(); for (id, n) in self.nodes.iter() { if n.id != *id { - issues.push(format!("node key {id} has id {}", n.id)); + note(&mut issues, || format!("node key {id} has id {}", n.id)); } let mut seen = std::collections::BTreeSet::new(); for c in &n.children { if !seen.insert(*c) { - issues.push(format!("child {c} listed twice in {id}")); + note(&mut issues, || format!("child {c} listed twice in {id}")); } match self.node(*c) { Some(cn) if cn.parent == Some(*id) => {} - Some(_) => issues.push(format!("child {c} of {id} has another parent")), - None => issues.push(format!("child {c} of {id} missing")), + Some(_) => note(&mut issues, || format!("child {c} of {id} has another parent")), + None => note(&mut issues, || format!("child {c} of {id} missing")), } } if let Some(p) = n.parent && !self.node(p).is_some_and(|pn| pn.children.contains(id)) { - issues.push(format!("{id} not listed in parent {p}")); + note(&mut issues, || format!("{id} not listed in parent {p}")); } // The library store's root is a page that is never listed. let listed = self.pages.contains(id) || (n.is_page() && self.libraries.store == Some(*id)); if n.is_page() != listed { - issues.push(format!("page list mismatch for {id}")); + note(&mut issues, || format!("page list mismatch for {id}")); } if n.is_page() && n.parent.is_some() { - issues.push(format!("page {id} has a parent")); + note(&mut issues, || format!("page {id} has a parent")); } } let reachable = self.reachable(); for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) { - issues.push(format!("{id} is not under a page (cycle or detached)")); + note(&mut issues, || format!("{id} is not under a page (cycle or detached)")); } issues } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 00f7d559..649d5e56 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -518,6 +518,9 @@ pub struct Node { /// Dev resources: links for developers (docs, code, tickets…), in the order they were added. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub dev_resources: Vec, + /// On a board: what this object is (a sticky note, a connector, a stamp…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub board: Option, } pub(crate) fn yes() -> bool { @@ -628,6 +631,7 @@ impl Node { annotations: Vec::new(), code_links: Vec::new(), dev_resources: Vec::new(), + board: None, } } @@ -653,6 +657,11 @@ impl Node { matches!(self.kind, NodeKind::Page { .. }) } + /// A board object selected and moved as a whole (a sticky, a shape with text, a stamp…). + pub fn is_board_atom(&self) -> bool { + self.board.as_ref().is_some_and(crate::BoardItem::is_atom) + } + /// A section whose contents are hidden: only its name and outline show; its children are /// skipped when rendering and hit testing. pub fn hides_contents(&self) -> bool { diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 4f6491c1..a646ae62 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -475,7 +475,7 @@ impl WidthPreset { } pub fn from_id(s: &str) -> Option { - let s: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect(); + let s: String = s.chars().filter(char::is_ascii_alphanumeric).collect(); WidthPreset::ALL .into_iter() .find(|p| p.id().eq_ignore_ascii_case(&s) || p.label().replace(' ', "").eq_ignore_ascii_case(&s)) diff --git a/crates/doc/src/proto_expr.rs b/crates/doc/src/proto_expr.rs index f4404dc7..d5441b28 100644 --- a/crates/doc/src/proto_expr.rs +++ b/crates/doc/src/proto_expr.rs @@ -169,7 +169,12 @@ impl Expr { }) .collect(); match (op, parts.as_slice()) { - (ExprOp::Not, [x]) => format!("not {x}"), + // `not done` reads as a variable named "not done" if there is one: then + // `not (done)`. + (ExprOp::Not, [x]) => { + let bare = format!("not {x}"); + if x.starts_with('(') || Expr::parse(&bare, vars).is_ok_and(|e| e == *self) { bare } else { format!("not ({x})") } + } (ExprOp::Negate, [x]) => format!("-{x}"), _ => parts.join(&format!(" {} ", op.symbol())), } diff --git a/crates/doc/src/proto_expr_parse.rs b/crates/doc/src/proto_expr_parse.rs index 5214a2d6..54615ee0 100644 --- a/crates/doc/src/proto_expr_parse.rs +++ b/crates/doc/src/proto_expr_parse.rs @@ -459,16 +459,19 @@ impl Parser<'_> { if self.at_keyword("mode") && self.next_after(4) == Some('(') { return self.mode_of(); } - if let Some((id, n)) = self.variable_here() { - self.i += n; - return self.in_mode(id); - } + let var = self.variable_here(); + // A bare `true`/`false` is the literal, also when a variable has that name (it reads + // as `{true}`); longer names that start with it ("true value") stay variables. for (word, v) in [("true", true), ("false", false)] { - if self.at_keyword(word) { + if self.at_keyword(word) && var.as_ref().is_none_or(|(_, n)| *n <= word.len()) { self.i += word.len(); return Ok(Expr::Value(VariableValue::Boolean(v))); } } + if let Some((id, n)) = var { + self.i += n; + return self.in_mode(id); + } let word: String = self.s.iter().skip(self.i).take_while(|c| ident(**c)).collect(); if word.is_empty() { Err(self.err(format!("Unexpected {c}"))) } else { Err(self.err(format!("No variable `{word}`"))) } } diff --git a/crates/doc/src/proto_expr_parse_tests.rs b/crates/doc/src/proto_expr_parse_tests.rs index e106121d..1cac9f53 100644 --- a/crates/doc/src/proto_expr_parse_tests.rs +++ b/crates/doc/src/proto_expr_parse_tests.rs @@ -195,3 +195,49 @@ fn names_that_look_like_literals_read_back_as_variables() { assert_eq!(parse("1"), n(1.0)); assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into()))); } + +#[test] +fn not_of_a_variable_reads_back_when_a_name_starts_with_not() { + use ExprOp::*; + let mut v = vars(); + let b = v.variables.iter().find(|x| x.id == "b").unwrap().clone(); + for (id, name) in [("done", "done"), ("nd", "not done")] { + let mut x = b.clone(); + x.id = id.into(); + x.name = name.into(); + v.variables.push(x); + } + let not_done = Expr::op(Not, vec![Expr::var("done")]); + for e in [ + not_done.clone(), + Expr::op(And, vec![Expr::var("b"), not_done.clone()]), + Expr::op(Equals, vec![not_done.clone(), Expr::Value(VariableValue::Boolean(true))]), + Expr::op(Not, vec![not_done.clone()]), + ] { + let shown = e.display(&v); + assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "shown as {shown:?}"); + } + assert_eq!(Expr::op(Not, vec![Expr::var("b")]).display(&vars()), "not b-name", "plain names stay bare"); +} + +#[test] +fn literal_true_false_survive_variables_named_true_false() { + use ExprOp::*; + let lit = |x: bool| Expr::Value(VariableValue::Boolean(x)); + for name in ["true", "false", "TRUE"] { + let mut v = vars(); + if let Some(var) = v.variables.iter_mut().find(|x| x.id == "b") { + var.name = name.into(); + } + for e in [ + Expr::op(Equals, vec![Expr::var("b"), lit(true)]), + Expr::op(Equals, vec![Expr::var("b"), lit(false)]), + Expr::op(Not, vec![lit(false)]), + Expr::var("b"), + ] { + let shown = e.display(&v); + assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}"); + } + assert_eq!(Expr::parse(&format!("{{{name}}}"), &v), Ok(Expr::var("b"))); + } +} diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a5ead3b7..1a006c26 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -326,6 +326,20 @@ fn repair_drops_cycles_and_repeated_children() { assert_eq!(bad.subtree(p), vec![p, r]); } +#[test] +fn validate_caps_the_issue_list() { + let mut d = Document::new("x"); + let p = page(&d); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); + let mut v = serde_json::to_value(&d).unwrap(); + v["nodes"][f.to_string()]["children"] = serde_json::json!(vec![g; 5000]); + let bad: Document = serde_json::from_value(v).unwrap(); + let issues = bad.validate(); + assert!(!issues.is_empty()); + assert!(issues.len() <= 100, "{}", issues.len()); +} + #[test] fn auto_layout_loads_files_without_grid_tracks() { // Saved before grid tracks, cells and spans existed. @@ -464,3 +478,84 @@ fn text_style_empty_range_links_and_unlinks_all() { e.unlink_text_style(0, 0); assert!(e.style.style_id.is_none()); } + +#[test] +fn stale_next_id_never_overwrites_existing_nodes() { + let mut d = Document::new("x"); + let p = page(&d); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let a = d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 10.0, 10.0).unwrap(); + let mut v = serde_json::to_value(&d).unwrap(); + v["next_id"] = serde_json::json!(1); + let mut bad: Document = serde_json::from_value(v).unwrap(); + let before: Vec<(NodeId, String, Option)> = + [p, f, a].iter().map(|i| (*i, bad.node(*i).unwrap().name.clone(), bad.node(*i).unwrap().parent)).collect(); + let n = bad.node_count(); + let copy = bad.duplicate_into(f, p, None).unwrap(); + assert!(![p, f, a].contains(©)); + assert_eq!(bad.node_count(), n + 2); + let copies = bad.duplicate_all_into(&[f], p).unwrap(); + assert_eq!(bad.node_count(), n + 4); + assert!(copies.iter().all(|c| ![p, f, a, copy].contains(c))); + for (i, name, parent) in &before { + let node = bad.node(*i).unwrap(); + assert_eq!((&node.name, node.parent), (name, *parent)); + } + assert!(bad.validate().is_empty(), "{:?}", bad.validate()); + + // A paste whose id map points at an existing node fails and changes nothing. + let nodes: Vec = bad.subtree(f).into_iter().filter_map(|i| bad.node(i).cloned()).collect(); + let mut map: BTreeMap = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect(); + map.insert(a, p); + let snapshot = bad.clone(); + assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err()); + assert_eq!(bad, snapshot); + assert_eq!(bad.node(p).unwrap().name, snapshot.node(p).unwrap().name); + assert!(bad.validate().is_empty(), "{:?}", bad.validate()); + + // So does one that maps two nodes to the same new id. + let mut map: BTreeMap = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect(); + map.insert(a, map[&f]); + assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err()); + assert_eq!(bad, snapshot); +} + +#[test] +fn hit_test_even_odd_vector_hole_misses() { + let ring = |rule| { + let sq = |o: f64, s: f64, base: usize| { + ( + [(o, o), (o + s, o), (o + s, o + s), (o, o + s)].map(|(x, y)| Vertex::new(x, y)), + [Segment::line(base, base + 1), Segment::line(base + 1, base + 2), Segment::line(base + 2, base + 3), Segment::line(base + 3, base)], + ) + }; + let (v1, s1) = sq(0.0, 200.0, 0); + let (v2, s2) = sq(60.0, 80.0, 4); + VectorNetwork { + vertices: v1.into_iter().chain(v2).collect(), + segments: s1.into_iter().chain(s2).collect(), + regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }], + } + }; + let mut d = Document::new("x"); + let p = page(&d); + let eo = d.create(p, NodeKind::Vector { network: ring(WindingRule::EvenOdd) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap(); + assert_eq!(d.hit_test(p, Point::new(20.0, 100.0), 0.0, None), Some(eo)); + assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), None, "the hole is not filled"); + let mut d = Document::new("x"); + let p = page(&d); + let nz = d.create(p, NodeKind::Vector { network: ring(WindingRule::NonZero) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap(); + assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), Some(nz)); +} + +#[test] +fn is_ancestor_holds_for_very_deep_chains() { + let mut d = Document::new("x"); + let root = d.create(page(&d), NodeKind::Frame, "root", 0.0, 0.0, 1.0, 1.0).unwrap(); + let mut leaf = root; + for _ in 0..10_005 { + leaf = d.create(leaf, NodeKind::Frame, "n", 0.0, 0.0, 1.0, 1.0).unwrap(); + } + assert!(d.is_ancestor(root, leaf)); + assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..)))); +} diff --git a/crates/engine/src/annotations_tests.rs b/crates/engine/src/annotations_tests.rs index 7fc7376f..00065049 100644 --- a/crates/engine/src/annotations_tests.rs +++ b/crates/engine/src/annotations_tests.rs @@ -171,7 +171,7 @@ fn ready_for_dev_lists_frames_by_status() { }; let ready = ex(&mut s, "inspect.readyForDev", json!({})); assert_eq!(names(&ready), vec!["Login", "Onboarding", "Profile"], "every page, page and layer order"); - assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev"})); + assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev", "changed": false})); assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]); assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any", "page": page}))), vec!["Login", "Signup", "Onboarding"]); assert!(s.execute("inspect.readyForDev", &json!({"status": "none"})).is_err()); diff --git a/crates/engine/src/binding_lists_tests.rs b/crates/engine/src/binding_lists_tests.rs index 9db9975a..8b221efd 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() { let fills = s.doc().unwrap().node(r).unwrap().fills.clone(); assert_eq!(fills.len(), 2, "the second fill stays"); assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) }); - assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included"); + assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity"); assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) }); // Removing the bound (first) fill takes the binding with it. ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0})); diff --git a/crates/engine/src/board.rs b/crates/engine/src/board.rs new file mode 100644 index 00000000..317b0889 --- /dev/null +++ b/crates/engine/src/board.rs @@ -0,0 +1,1307 @@ +//! Boards: files for thinking together, with sticky notes, shapes with text, connectors, ink and +//! stamps. Board objects are ordinary layers with a [`BoardItem`] on them; [`settle_text`] and +//! [`settle_routes`] keep what follows from it (a sticky's text size, a shape's text room, +//! connectors' routes and their labels) up to date in the normalize pass, so local edits, undo and +//! live-session edits from others all end in the same geometry. + +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_color::{BlendMode, Color, Paint}; +use figcraft_doc::{ + AutoLayout, BOARD_COLORS, BoardItem, BoardShape, Connector, Constraints, CrossAlign, DocError, Document, Effect, EffectKind, End, FileKind, + HConstraint, LayoutMode, MainAlign, Node, NodeId, NodeKind, Routing, STAMP_SIZE, Shadow, Side, Sizing, Stamp, StrokeAlign, StrokeCap, StrokeJoin, + TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color, +}; +use figcraft_geom::{Affine, Point, Rect}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::{boolean, color_of, get, id, id_of, num, num_or, opt_id, req_num, req_string, req_targets, string}; +use crate::tools::{Gesture, PointerEvent, ToolId}; +use crate::{EngineError, Result, Session, bad, spec, textedit}; + +/// Why a board command refuses on a design file. +pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board"; +/// Why prototyping and Dev Mode refuse on a board. +pub const NOT_ON_BOARDS: &str = "boards have no prototypes or Dev Mode: use a design file for those"; + +/// A sticky note's side (it grows taller for long notes). +pub const STICKY: f64 = 200.0; +const STICKY_PAD: f64 = 16.0; +/// The room between a sticky and the next one made beside it. +const STICKY_GAP: f64 = 24.0; +/// A new shape's size. +const SHAPE: f64 = 160.0; +/// Text on board objects, and the author line under a sticky's note. +const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E); +const AUTHOR_INK: Color = Color::from_rgb8_const(0x5C, 0x5A, 0x55); +/// Connectors' default colour and weight. +const LINE: Color = Color::from_rgb8_const(0x3A, 0x39, 0x36); +const LINE_WEIGHT: f64 = 2.0; +/// Marker and highlighter strokes: (weight, opacity). +const MARKER: (f64, f32) = (4.0, 1.0); +const HIGHLIGHTER: (f64, f32) = (20.0, 0.45); +/// How far the eraser reaches around the pointer (screen pixels). +const ERASER_PX: f64 = 8.0; +/// The text sizes the contextual bar offers. +pub const TEXT_SIZES: [(&str, f64); 4] = [("S", 12.0), ("M", 16.0), ("L", 24.0), ("XL", 36.0)]; + +/// The board tools' choices: the colour new stickies get, the shape and stamp the tools place, +/// how new connectors run and the marker's and highlighter's colours. +#[derive(Clone, Debug)] +pub struct BoardTool { + pub sticky: Color, + pub shape: BoardShape, + pub stamp: Stamp, + pub routing: Routing, + pub marker: Color, + pub highlighter: Color, +} + +impl Default for BoardTool { + fn default() -> Self { + let first = BOARD_COLORS.first().map_or(Color::WHITE, |c| c.1); + Self { + sticky: first, + shape: BoardShape::Rectangle, + stamp: Stamp::ThumbsUp, + routing: Routing::Elbow, + marker: INK, + highlighter: Color::from_rgb8_const(0xFF, 0xD2, 0x1F), + } + } +} + +pub fn specs() -> Vec { + vec![ + spec!( + "board.sticky", + "Sticky note", + "{x, y (its top-left, in the parent), text?, color? (yellow|orange|red|pink|violet|blue|green|gray or hex; default the sticky tool's), author? (a name, or false for none; default yours), parent?} → {id, text}", + true, + sticky_cmd + ), + spec!( + "board.nextSticky", + "Next sticky note", + "{id? (a sticky; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text} a sticky of the same colour beside it", + true, + next_sticky + ), + spec!( + "board.shape", + "Shape with text", + "{shape: rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron, x, y, width?, height? (default 160), text?, color?, parent?} → {id, text}", + true, + shape_cmd + ), + spec!( + "board.connector", + "Connector", + "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", + true, + connector_cmd + ), + spec!("board.label", "Connector label", "{id (a connector; default the selection), text (empty removes it)} → {id, label}", true, label_cmd), + spec!( + "board.stamp", + "Stamp", + "{x, y (its centre on the page), stamp: thumbsUp|heart|plusOne|star|question|check} → {id} a badge with your name on hover", + true, + stamp_cmd + ), + spec!( + "board.ink", + "Marker stroke", + "{path (SVG path data on the page) | points [[x,y],..], highlighter? (wide and see-through), color?, select? (default true)} → {id}", + true, + ink_cmd + ), + spec!( + "board.setColor", + "Board color", + "{ids?, color (a board colour name or hex)} stickies and shapes take it as their fill, connectors and ink as their line", + true, + set_color + ), + spec!( + "board.setStyle", + "Board style", + "{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow, startCap?, endCap? (connectors)}", + true, + set_style + ), + spec!("tool.sticky", "Sticky note", "{color?} (a click places a sticky and starts typing in it)", false, |s, p| { + if let Some(c) = color_param("tool.sticky", p)? { + s.tool.board.sticky = c; + } + board_tool(s, ToolId::Sticky) + }), + spec!("tool.shape", "Shapes", "{shape?} (a click places a shape with text)", false, |s, p| { + if let Some(v) = string(p, "shape") { + s.tool.board.shape = shape_of("tool.shape", &v)?; + } + board_tool(s, ToolId::BoardShape) + }), + spec!("tool.connector", "Connector", "{style?: straight|elbow} (drag from one object to another)", false, |s, p| { + if let Some(v) = string(p, "style") { + s.tool.board.routing = routing_of("tool.connector", &v)?; + } + board_tool(s, ToolId::Connector) + }), + spec!("tool.marker", "Marker", "{color?}", false, |s, p| { + if let Some(c) = color_param("tool.marker", p)? { + s.tool.board.marker = c; + } + board_tool(s, ToolId::Marker) + }), + spec!("tool.highlighter", "Highlighter", "{color?}", false, |s, p| { + if let Some(c) = color_param("tool.highlighter", p)? { + s.tool.board.highlighter = c; + } + board_tool(s, ToolId::Highlighter) + }), + spec!("tool.eraser", "Eraser", "{} (drag over marker and highlighter strokes to remove them)", false, |s, _| board_tool(s, ToolId::Eraser)), + spec!("tool.stamp", "Stamp", "{stamp?} (a click places the stamp)", false, |s, p| { + if let Some(v) = string(p, "stamp") { + s.tool.board.stamp = stamp_of("tool.stamp", &v)?; + } + board_tool(s, ToolId::Stamp) + }), + ] +} + +pub fn is_board(doc: &Document) -> bool { + doc.kind == FileKind::Board +} + +/// Refuse `cmd` unless the active file is a board. +pub(crate) fn require(s: &Session, cmd: &str) -> Result<()> { + if s.doc().is_ok_and(is_board) { Ok(()) } else { Err(EngineError::Disabled(cmd.into(), NEEDS_BOARD.into())) } +} + +/// Prototyping and Dev Mode: not on boards. +fn design_only(cmd: &str) -> bool { + cmd.starts_with("proto.") || cmd.starts_with("dev.") || matches!(cmd, "view.prototype" | "view.designPrototype" | "view.devMode") +} + +/// Refuse `cmd` on a board when it is prototyping or Dev Mode. +pub(crate) fn refuse_on_board(s: &Session, cmd: &str) -> Result<()> { + if design_only(cmd) && s.doc().is_ok_and(is_board) { Err(EngineError::Disabled(cmd.into(), NOT_ON_BOARDS.into())) } else { Ok(()) } +} + +fn board_tool(s: &mut Session, t: ToolId) -> Result { + require(s, &format!("tool.{}", t.id()))?; + crate::cmd::view::tool(s, t) +} + +fn shape_of(cmd: &str, v: &str) -> Result { + BoardShape::from_id(v) + .ok_or_else(|| bad(cmd, format!("unknown shape `{v}` (rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron)"))) +} + +fn stamp_of(cmd: &str, v: &str) -> Result { + Stamp::from_id(v).ok_or_else(|| bad(cmd, format!("unknown stamp `{v}` (thumbsUp|heart|plusOne|star|question|check)"))) +} + +fn routing_of(cmd: &str, v: &str) -> Result { + Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight or elbow, not `{v}`"))) +} + +fn cap_of(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(v) => crate::cmd::props::enum_of::(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}`: unknown cap"))), + } +} + +/// `color`: a board colour's name, or any colour. +fn color_param(cmd: &str, p: &Value) -> Result> { + match get(p, "color") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) if board_color(s).is_some() => Ok(board_color(s)), + Some(v) => color_of(v).map(Some).ok_or_else(|| bad(cmd, "`color` is a board colour (yellow, blue…) or a colour such as #FFE78A")), + } +} + +/// The deeper shade of a board colour lines are drawn in (light tints are too faint as lines). +fn line_shade(c: Color) -> Color { + let palette = BOARD_COLORS.iter().any(|(_, b)| *b == c); + if palette { Color::new(c.r * 0.55, c.g * 0.55, c.b * 0.55, c.a) } else { c } +} + +/// Your name on what you make: the name from Preferences, else the one a live session gave you +/// (none for the default name outside a session). +fn author(s: &Session) -> String { + let name = crate::live::display_name(&s.prefs); + if name.is_empty() { s.live().info.as_ref().map(|i| i.name.clone()).unwrap_or_default() } else { name } +} + +/// The parent a command places into, and that parent's page-to-local transform. +fn parent_of(cmd: &str, s: &Session, p: &Value) -> Result { + let page = s.page()?; + let parent = opt_id(cmd, p, "parent")?.unwrap_or(page); + let doc = s.doc()?; + let n = doc.get(parent)?; + let takes = n.is_page() || matches!(n.kind, NodeKind::Section { .. }) || (n.kind.is_frame_like() && !n.is_board_atom()); + if !takes { + return Err(bad(cmd, "`parent` is the page, a section or a frame")); + } + Ok(parent) +} + +/// A new layer at the top of `parent`, at `at` in it, made by `f`. +fn add( + doc: &mut Document, + parent: NodeId, + kind: NodeKind, + name: &str, + at: (f64, f64), + size: (f64, f64), + f: impl FnOnce(&mut Node), +) -> Result { + let id = doc.alloc_id(); + let mut n = Node::new(id, kind, name, size.0, size.1); + n.transform = Affine::translate(at); + f(&mut n); + doc.insert(n, parent, None)?; + Ok(id) +} + +/// A soft shadow under stickies and stamps. +fn soft_shadow() -> Effect { + Effect { + kind: EffectKind::DropShadow(Shadow { color: Color::new(0.0, 0.0, 0.0, 0.16), offset: (0.0, 2.0), radius: 6.0, ..Shadow::default() }), + visible: true, + } +} + +/// A text layer filling its frame's width (the frame's auto layout places it). +fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result { + let first: String = text.lines().next().unwrap_or("").chars().take(64).collect(); + let name = if first.trim().is_empty() { "Text" } else { first.as_str() }; + let mut td = TextData { + characters: crate::cmd::props::normalize_line_endings(text), + auto_resize: TextAutoResize::Height, + align_h: align, + ..TextData::default() + }; + td.style.font_size = size; + let id = add(doc, parent, NodeKind::Text(td), name, (0.0, 0.0), (width.max(1.0), size * 1.4), |n| { + n.fills = vec![Paint::solid(color)]; + n.child_layout.sizing_h = Sizing::Fill; + })?; + if let Some(n) = doc.node(id) + && let Some((w, h)) = figcraft_text::auto_size(n) + && let Some(n) = doc.node_mut(id) + { + (n.width, n.height) = (w, h); + } + Ok(id) +} + +/// The text size a sticky's note gets: smaller the more it holds (by characters and lines, so +/// every copy of the file picks the same size whatever fonts it has). +pub fn sticky_font_size(text: &str) -> f64 { + let weight = text.chars().count() + 24 * text.matches('\n').count(); + match weight { + 0..=24 => 24.0, + 25..=64 => 20.0, + 65..=140 => 16.0, + 141..=280 => 13.0, + _ => 11.0, + } +} + +/// A sticky note's layers: the frame, the note and (when known) its author. +fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, color: Color, author: &str) -> Result<(NodeId, NodeId)> { + let id = add(doc, parent, NodeKind::Frame, "Sticky", at, (STICKY, STICKY), |n| { + n.fills = vec![Paint::solid(color)]; + n.corners.radii = [2.0; 4]; + n.effects = vec![soft_shadow()]; + n.clip = false; + n.layout = Some(AutoLayout { + mode: LayoutMode::Vertical, + gap: 8.0, + padding: [STICKY_PAD; 4], + main_align: MainAlign::SpaceBetween, + ..AutoLayout::default() + }); + n.child_layout.sizing_v = Sizing::Hug; + n.child_layout.min_h = Some(STICKY); + n.board = Some(BoardItem::Sticky { author: author.to_string() }); + })?; + let inner = STICKY - 2.0 * STICKY_PAD; + let note = add_text(doc, id, text, sticky_font_size(text), INK, TextAlignH::Left, inner)?; + if !author.is_empty() { + let a = add_text(doc, id, author, 10.0, AUTHOR_INK, TextAlignH::Left, inner)?; + if let Some(n) = doc.node_mut(a) { + n.name = "Author".into(); + } + } + Ok((id, note)) +} + +fn sticky_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.sticky"; + require(s, cmd)?; + let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); + let author = match get(p, "author") { + Some(Value::String(a)) => a.trim().chars().filter(|c| !c.is_control()).take(64).collect(), + Some(Value::Bool(false)) => String::new(), + _ => author(s), + }; + let parent = parent_of(cmd, s, p)?; + let text = string(p, "text").unwrap_or_default(); + let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); + let (id, note) = make_sticky(s.doc_mut()?, parent, at, &text, color, &author)?; + s.set_selection(vec![id]); + Ok(json!({"id": id, "text": note})) +} + +/// The first text layer inside board object `id` (its note or label). +pub fn text_of(doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + n.board.as_ref().filter(|b| b.has_text())?; + n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_)))) +} + +/// The board object whose text `text` is, if any. +pub fn owner_of_text(doc: &Document, text: NodeId) -> Option { + let p = doc.node(text)?.parent?; + (text_of(doc, p) == Some(text)).then_some(p) +} + +/// Start typing in board object `id`'s text (all of it selected). False when it has none. +pub fn edit_text(s: &mut Session, id: NodeId) -> Result { + let Some(t) = s.doc().ok().and_then(|d| text_of(d, id)) else { return Ok(false) }; + textedit::begin(s, t, None, false)?; + textedit::key(s, "SelectAll", false, false, false)?; + Ok(true) +} + +fn next_sticky(s: &mut Session, p: &Value) -> Result { + let cmd = "board.nextSticky"; + require(s, cmd)?; + let editing = s.ds()?.text_edit.as_ref().map(|t| t.id); + let doc = s.doc()?; + let from = id(p, "id") + .or_else(|| editing.and_then(|t| owner_of_text(doc, t))) + .or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i))) + .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?; + let n = doc.get(from)?; + if !matches!(n.board, Some(BoardItem::Sticky { .. })) { + return Err(bad(cmd, "that isn't a sticky")); + } + let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky); + let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?; + let at = n.transform.translation(); + let (dx, dy) = match string(p, "side").as_deref().unwrap_or("right") { + "right" => (n.width + STICKY_GAP, 0.0), + "left" => (-(STICKY + STICKY_GAP), 0.0), + "below" => (0.0, n.height + STICKY_GAP), + "above" => (0.0, -(STICKY + STICKY_GAP)), + other => return Err(bad(cmd, format!("`side` is right, below, left or above, not `{other}`"))), + }; + let author = author(s); + if editing.is_some() { + textedit::end(s)?; + } + let (id, note) = make_sticky(s.doc_mut()?, parent, (at.x + dx, at.y + dy), "", color, &author)?; + s.set_selection(vec![id]); + if boolean(p, "edit") == Some(true) { + // Typing goes on in the new sticky once this step is recorded. + s.request_ui("board.editText", json!({"id": id})); + } + Ok(json!({"id": id, "text": note})) +} + +/// A shape with text's layers: the frame, its outline and its text. +fn make_shape( + doc: &mut Document, + parent: NodeId, + at: (f64, f64), + size: (f64, f64), + shape: BoardShape, + text: &str, + color: Color, +) -> Result<(NodeId, NodeId)> { + let (w, h) = size; + let id = add(doc, parent, NodeKind::Frame, shape.label(), at, size, |n| { + n.fills.clear(); + n.clip = false; + n.layout = Some(AutoLayout { + mode: LayoutMode::Vertical, + padding: shape_padding(shape, w, h), + main_align: MainAlign::Center, + cross_align: CrossAlign::Center, + ..AutoLayout::default() + }); + n.board = Some(BoardItem::Shape { shape }); + })?; + add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&shape.path(w, h)) }, "Outline", (0.0, 0.0), size, |n| { + n.fills = vec![Paint::solid(color)]; + n.strokes = vec![Paint::solid(line_shade(color))]; + n.stroke.weight = 1.5; + n.stroke.align = StrokeAlign::Inside; + n.child_layout.absolute = true; + n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::Scale }; + })?; + let [_, r, _, l] = shape_padding(shape, w, h); + let label = add_text(doc, id, text, 16.0, INK, TextAlignH::Center, (w - l - r).max(1.0))?; + Ok((id, label)) +} + +fn shape_padding(shape: BoardShape, w: f64, h: f64) -> [f64; 4] { + let [t, r, b, l] = shape.text_insets(); + [t * h, r * w, b * h, l * w] +} + +fn shape_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.shape"; + require(s, cmd)?; + let shape = match string(p, "shape") { + Some(v) => shape_of(cmd, &v)?, + None => s.tool.board.shape, + }; + let size = |k| { + num(p, k) + .map_or(Ok(SHAPE), |v| if (1.0..=100_000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{k}` is a size from 1 to 100000"))) }) + }; + let (w, h) = (size("width")?, size("height")?); + let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); + let parent = parent_of(cmd, s, p)?; + let text = string(p, "text").unwrap_or_default(); + let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); + let (id, label) = make_shape(s.doc_mut()?, parent, at, (w, h), shape, &text, color)?; + s.set_selection(vec![id]); + Ok(json!({"id": id, "text": label})) +} + +fn stamp_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.stamp"; + require(s, cmd)?; + let stamp = match string(p, "stamp") { + Some(v) => stamp_of(cmd, &v)?, + None => s.tool.board.stamp, + }; + let (x, y) = (req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); + let author = author(s); + let page = s.page()?; + let half = STAMP_SIZE / 2.0; + let doc = s.doc_mut()?; + let id = add(doc, page, NodeKind::Frame, stamp.label(), (x - half, y - half), (STAMP_SIZE, STAMP_SIZE), |n| { + n.fills = vec![Paint::solid(stamp.color())]; + n.corners.radii = [half; 4]; + n.effects = vec![soft_shadow()]; + n.clip = false; + n.board = Some(BoardItem::Stamp { stamp, author }); + })?; + for part in stamp.mark() { + let path = figcraft_geom::BezPath::from_svg(part.d).map_err(|e| EngineError::Other(format!("stamp mark: {e}")))?; + add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&path) }, "Mark", (0.0, 0.0), (STAMP_SIZE, STAMP_SIZE), |n| { + if part.filled { + n.fills = vec![Paint::solid(Color::WHITE)]; + } else { + n.fills.clear(); + n.strokes = vec![Paint::solid(Color::WHITE)]; + n.stroke.weight = 3.0; + (n.stroke.cap_start, n.stroke.cap_end) = (StrokeCap::Round, StrokeCap::Round); + n.stroke.join = StrokeJoin::Round; + n.stroke.align = StrokeAlign::Center; + } + })?; + } + s.set_selection(vec![id]); + Ok(json!({"id": id})) +} + +fn ink_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.ink"; + require(s, cmd)?; + let high = boolean(p, "highlighter").unwrap_or(false); + let color = color_param(cmd, p)?.unwrap_or(if high { s.tool.board.highlighter } else { s.tool.board.marker }); + let (weight, opacity) = if high { HIGHLIGHTER } else { MARKER }; + if get(p, "path").is_none() && get(p, "points").is_none() { + return Err(bad(cmd, "`path` or `points` is required")); + } + let page = s.page()?; + let select = boolean(p, "select").unwrap_or(true); + let mut q = json!({"type": "vector", "parent": page, "closed": false, "select": select, "name": if high { "Highlight" } else { "Ink" }, "props": { + "fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center", + }}); + for k in ["path", "points"] { + if let (Some(v), Some(o)) = (get(p, k), q.as_object_mut()) { + o.insert(k.into(), v.clone()); + } + } + let made = crate::cmd::create::create(s, &q)?; + let id = made.get("id").and_then(id_of).ok_or_else(|| EngineError::Other("no stroke made".into()))?; + let n = s.doc_mut()?.get_mut(id)?; + n.opacity = opacity; + if high { + n.blend = BlendMode::Multiply; + } + n.board = Some(BoardItem::Ink); + Ok(json!({"id": id})) +} + +/// One connector end from the params: a layer on this page, or a point. +fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result { + let side = match string(p, side_key) { + Some(v) => Side::from_id(&v).ok_or_else(|| bad(cmd, format!("`{side_key}` is auto, top, right, bottom or left")))?, + None => Side::Auto, + }; + match get(p, key) { + Some(Value::Object(o)) if o.contains_key("x") => { + let (x, y) = (num(&Value::Object(o.clone()), "x"), num(&Value::Object(o.clone()), "y")); + match (x, y) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Ok(End::Point { x, y }), + _ => Err(bad(cmd, format!("`{key}` needs finite x and y"))), + } + } + Some(v) => { + let node = id_of(v).ok_or_else(|| bad(cmd, format!("`{key}` is a layer id or {{x, y}}")))?; + let doc = s.doc()?; + let n = doc.get(node)?; + if n.is_page() || doc.page_of(node) != Some(s.page()?) { + return Err(bad(cmd, format!("`{key}` must be a layer on this page"))); + } + if matches!(n.board, Some(BoardItem::Connector(_))) { + return Err(bad(cmd, format!("`{key}` can't be another connector"))); + } + Ok(End::Node { node, side }) + } + None => Err(bad(cmd, format!("`{key}` is required"))), + } +} + +fn connector_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.connector"; + require(s, cmd)?; + let from = end_param(cmd, s, p, "from", "fromSide")?; + let to = end_param(cmd, s, p, "to", "toSide")?; + if from.node().is_some() && from.node() == to.node() { + return Err(bad(cmd, "connect two different layers")); + } + let routing = match string(p, "style") { + Some(v) => routing_of(cmd, &v)?, + None => s.tool.board.routing, + }; + let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None); + let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow); + let c = Connector { from, to, routing }; + let page = s.page()?; + let doc = s.doc_mut()?; + let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; + if pts.windows(2).all(|w| w.first() == w.get(1)) { + return Err(bad(cmd, "the ends are in the same place")); + } + let id = add(doc, page, NodeKind::Vector { network: VectorNetwork::default() }, "Connector", (0.0, 0.0), (0.0, 0.0), |n| { + n.fills.clear(); + n.strokes = vec![Paint::solid(LINE)]; + n.stroke.weight = LINE_WEIGHT; + n.stroke.join = StrokeJoin::Round; + n.stroke.cap_start = start; + n.stroke.cap_end = end; + n.board = Some(BoardItem::Connector(c)); + })?; + place_route(doc, id, &pts); + let mut out = json!({"id": id}); + if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) { + out["label"] = json!(set_label(doc, id, &text)?); + } + s.set_selection(vec![id]); + Ok(out) +} + +/// The label of connector `id`, if it has one. +pub fn label_of(doc: &Document, id: NodeId) -> Option { + let parent = doc.node(id)?.parent?; + doc.node(parent)?.children.iter().copied().find(|c| doc.node(*c).is_some_and(|n| n.board == Some(BoardItem::Label { connector: id }))) +} + +/// Give connector `id` the label `text` (empty removes it); the label's id. +fn set_label(doc: &mut Document, id: NodeId, text: &str) -> Result> { + let existing = label_of(doc, id); + if text.trim().is_empty() { + if let Some(l) = existing { + doc.remove(l)?; + } + return Ok(None); + } + if let Some(l) = existing { + if let Some(t) = text_of(doc, l) + && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) + { + let len = td.char_count(); + td.replace_range(0, len, text); + } + return Ok(Some(l)); + } + let parent = doc.node(id).and_then(|n| n.parent).ok_or(DocError::NoNode(id))?; + let l = add(doc, parent, NodeKind::Frame, "Label", (0.0, 0.0), (40.0, 24.0), |n| { + n.fills = vec![Paint::solid(Color::WHITE)]; + n.corners.radii = [4.0; 4]; + n.clip = false; + n.layout = Some(AutoLayout { padding: [3.0, 6.0, 3.0, 6.0], ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Hug; + n.child_layout.sizing_v = Sizing::Hug; + n.board = Some(BoardItem::Label { connector: id }); + })?; + let t = add_text(doc, l, text, 12.0, INK, TextAlignH::Center, 40.0)?; + if let Some(n) = doc.node_mut(t) + && let NodeKind::Text(td) = &mut n.kind + { + // The label hugs its text: one line per paragraph. + td.auto_resize = TextAutoResize::WidthAndHeight; + n.child_layout.sizing_h = Sizing::Hug; + } + Ok(Some(l)) +} + +fn label_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.label"; + require(s, cmd)?; + let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; + let doc = s.doc()?; + let id = match doc.node(id).and_then(|n| n.board.clone()) { + Some(BoardItem::Connector(_)) => id, + Some(BoardItem::Label { connector }) => connector, + _ => return Err(bad(cmd, "that isn't a connector")), + }; + let text = req_string(cmd, p, "text")?; + let label = set_label(s.doc_mut()?, id, &text)?; + Ok(json!({"id": id, "label": label})) +} + +/// The board objects among `ids`, each with its item (a label counts as its connector's text). +fn items(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, BoardItem)> { + ids.iter().filter_map(|i| doc.node(*i).and_then(|n| n.board.clone()).map(|b| (*i, b))).collect() +} + +fn set_color(s: &mut Session, p: &Value) -> Result { + let cmd = "board.setColor"; + require(s, cmd)?; + let ids = req_targets(cmd, s, p)?; + let color = color_param(cmd, p)?.ok_or_else(|| bad(cmd, "`color` is required"))?; + let doc = s.doc_mut()?; + let mut changed = Vec::new(); + for (id, item) in items(doc, &ids) { + match item { + BoardItem::Sticky { .. } => doc.get_mut(id)?.fills = vec![Paint::solid(color)], + BoardItem::Shape { .. } => { + let Some(outline) = doc.get(id)?.children.first().copied() else { continue }; + let n = doc.get_mut(outline)?; + n.fills = vec![Paint::solid(color)]; + n.strokes = vec![Paint::solid(line_shade(color))]; + } + BoardItem::Connector(_) | BoardItem::Ink => doc.get_mut(id)?.strokes = vec![Paint::solid(line_shade(color))], + BoardItem::Label { .. } | BoardItem::Stamp { .. } => continue, + } + changed.push(id); + } + if changed.is_empty() { + return Err(bad(cmd, "select stickies, shapes, connectors or ink")); + } + Ok(json!({"ids": changed})) +} + +/// What `board.setStyle` changes (each `None` leaves it). +struct Restyle { + shape: Option, + routing: Option, + size: Option, + start: Option, + end: Option, +} + +impl Restyle { + fn of(cmd: &str, p: &Value) -> Result { + let size = num(p, "fontSize") + .map(|v| if v.is_finite() && (1.0..=1000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, "`fontSize` is from 1 to 1000")) }) + .transpose()?; + Ok(Restyle { + shape: string(p, "shape").map(|v| shape_of(cmd, &v)).transpose()?, + routing: string(p, "style").map(|v| routing_of(cmd, &v)).transpose()?, + size, + start: cap_of(cmd, p, "startCap")?, + end: cap_of(cmd, p, "endCap")?, + }) + } + + /// Restyle board object `id` (`item`); whether anything about it changed. + fn apply(&self, doc: &mut Document, id: NodeId, item: BoardItem) -> Result { + let changed = match item { + BoardItem::Shape { shape } => match self.shape.filter(|n| *n != shape) { + Some(new) => { + reshape(doc, id, new)?; + true + } + None => false, + }, + BoardItem::Connector(c) => self.restyle_connector(doc, id, c)?, + _ => false, + }; + // Text sizes: shapes' and labels' text (stickies size theirs to fit). + let sized = match (self.size, &item) { + (Some(size), BoardItem::Shape { .. } | BoardItem::Label { .. }) => resize_text(doc, id, size), + _ => false, + }; + Ok(changed || sized) + } + + fn restyle_connector(&self, doc: &mut Document, id: NodeId, mut c: Connector) -> Result { + let n = doc.get_mut(id)?; + if let Some(r) = self.routing { + c.routing = r; + n.board = Some(BoardItem::Connector(c)); + } + if let Some(cap) = self.start { + n.stroke.cap_start = cap; + } + if let Some(cap) = self.end { + n.stroke.cap_end = cap; + } + Ok(self.routing.is_some() || self.start.is_some() || self.end.is_some()) + } +} + +/// Shape with text `id` takes outline `new` (its text room too). +fn reshape(doc: &mut Document, id: NodeId, new: BoardShape) -> Result<()> { + let n = doc.get_mut(id)?; + n.board = Some(BoardItem::Shape { shape: new }); + n.name = new.label().into(); + let (w, h) = (n.width, n.height); + if let Some(l) = n.layout.as_mut() { + l.padding = shape_padding(new, w, h); + } + if let Some(o) = doc.get(id)?.children.first().copied() + && let Some(on) = doc.node_mut(o) + { + on.kind = NodeKind::Vector { network: VectorNetwork::from_path(&new.path(w, h)) }; + (on.width, on.height, on.transform) = (w, h, Affine::IDENTITY); + } + Ok(()) +} + +/// Board object `id`'s text at `size`; whether it has text. +fn resize_text(doc: &mut Document, id: NodeId, size: f64) -> bool { + let Some(t) = text_of(doc, id) else { return false }; + if let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) { + set_font_size(td, size); + return true; + } + false +} + +fn set_style(s: &mut Session, p: &Value) -> Result { + let cmd = "board.setStyle"; + require(s, cmd)?; + let ids = req_targets(cmd, s, p)?; + let restyle = Restyle::of(cmd, p)?; + let doc = s.doc_mut()?; + let mut changed = Vec::new(); + for (id, item) in items(doc, &ids) { + if restyle.apply(doc, id, item)? { + changed.push(id); + } + } + if changed.is_empty() { + return Err(bad(cmd, "nothing selected takes these (shapes with text, connectors, labels)")); + } + Ok(json!({"ids": changed})) +} + +fn set_font_size(td: &mut TextData, size: f64) { + td.style.font_size = size; + for r in &mut td.runs { + r.style.font_size = size; + } +} + +/// Each layer of `sources` (copied one by one, in order, as `copies`) and its copy: the whole +/// subtrees pair up when they have the same shape (an instance made of a main component may not; +/// then only the roots do). +pub(crate) fn copy_map(doc: &Document, sources: &[NodeId], copies: &[NodeId]) -> BTreeMap { + let mut map = BTreeMap::new(); + for (s, c) in sources.iter().zip(copies) { + let (a, b) = (doc.subtree(*s), doc.subtree(*c)); + if a.len() == b.len() { + map.extend(a.into_iter().zip(b)); + } else { + map.insert(*s, *c); + } + } + map +} + +/// The connectors and labels among copied layers, as `(copy, the original's board item)`, from the +/// originals in `doc` (`map`: original → copy). +pub(crate) fn copied_items(doc: &Document, map: &BTreeMap) -> Vec<(NodeId, BoardItem)> { + map.iter().filter_map(|(o, c)| doc.node(*o).and_then(|n| n.board.clone()).map(|b| (*c, b))).collect() +} + +/// After a copy (`map`: each copied original → its copy; `originals`: the copies' items as the +/// originals had them), copied connectors join the copies of what they joined and labels follow +/// the copies of their connectors. From another file (`foreign`), what wasn't copied isn't here: a +/// connector's end there stays where it is drawn, as a point, and a label without its connector +/// goes. +pub(crate) fn rejoin(doc: &mut Document, originals: &[(NodeId, BoardItem)], map: &BTreeMap, foreign: bool) { + for (copy, item) in originals { + let mut item = item.clone(); + let copied = |n: NodeId| map.contains_key(&n); + match &mut item { + BoardItem::Connector(c) if foreign => { + let ends = ends_now(doc, *copy); + for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { + if let (End::Node { node, .. }, Some(p)) = (*end, at) + && !copied(node) + { + *end = End::Point { x: p.x, y: p.y }; + } + } + } + BoardItem::Label { connector } if foreign && !copied(*connector) => { + let _ = doc.remove(*copy); + continue; + } + BoardItem::Connector(_) | BoardItem::Label { .. } => {} + _ => continue, + } + item.remap(map); + if let Some(n) = doc.node_mut(*copy) { + n.board = Some(item); + } + } +} + +// --------------------------------------------------------------------------------------------- +// Derived data (the normalize pass). + +/// Before text is measured: a sticky's note takes the size its length calls for, and a resized +/// shape keeps its text inside its outline. +pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTreeSet) { + if !is_board(doc) { + return; + } + for &id in touched { + let Some(n) = doc.node(id) else { continue }; + if let Some(owner) = + owner_of_text(doc, id).or(Some(id)).filter(|o| doc.node(*o).is_some_and(|k| matches!(k.board, Some(BoardItem::Sticky { .. })))) + && let Some(t) = text_of(doc, owner) + && let Some(NodeKind::Text(td)) = doc.node(t).map(|k| &k.kind) + { + let size = sticky_font_size(&td.characters); + let off = td.style.font_size != size || td.runs.iter().any(|r| r.style.font_size != size); + if off && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|k| &mut k.kind) { + set_font_size(td, size); + } + continue; + } + if let Some(BoardItem::Shape { shape }) = n.board + && before.node(id).is_none_or(|b| (b.width, b.height) != (n.width, n.height)) + { + let pad = shape_padding(shape, n.width, n.height); + if n.layout.as_ref().is_some_and(|l| l.padding != pad) + && let Some(l) = doc.node_mut(id).and_then(|k| k.layout.as_mut()) + { + l.padding = pad; + } + } + } +} + +/// After layout: connectors run between their ends again (an end whose layer is gone stays where +/// it was), one moved on its own takes its free ends along, and labels sit at their middles. Only +/// the connectors and labels an edit can have moved are looked at: touched themselves, or with a +/// touched (or gone) end, connector or layer around them. +pub(crate) fn settle_routes(doc: &mut Document, before: &Document, touched: &BTreeSet) { + if !is_board(doc) || touched.is_empty() { + return; + } + let hit = |doc: &Document, id: NodeId| touched.contains(&id) || doc.ancestors(id).iter().any(|a| touched.contains(a)); + let (mut connectors, mut labels) = (Vec::new(), Vec::new()); + for n in doc.nodes() { + match &n.board { + Some(BoardItem::Connector(c)) => { + let ends = [c.from.node(), c.to.node()].into_iter().flatten(); + if hit(doc, n.id) || ends.into_iter().any(|e| !doc.contains(e) || hit(doc, e)) { + connectors.push((n.id, *c)); + } + } + Some(BoardItem::Label { connector }) => labels.push((n.id, *connector)), + _ => {} + } + } + let rerouted: BTreeSet = connectors.iter().map(|(id, _)| *id).collect(); + for (id, mut c) in connectors { + let changed = detach_gone_ends(doc, id, &mut c) | carry_free_ends(doc, before, touched, id, &mut c); + if changed && let Some(n) = doc.node_mut(id) { + n.board = Some(BoardItem::Connector(c)); + } + if let Some(pts) = route(doc, &c) { + place_route(doc, id, &pts); + } + } + for (label, connector) in labels { + if rerouted.contains(&connector) || touched.contains(&connector) || hit(doc, label) { + place_label(doc, label, connector); + } + } +} + +/// The ends of connector `id` (as `c`) whose layer is gone or on another page stay where they are +/// drawn, as points. Whether any did. +fn detach_gone_ends(doc: &Document, id: NodeId, c: &mut Connector) -> bool { + let (page, ends) = (doc.page_of(id), ends_now(doc, id)); + let mut detached = false; + for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { + if let (End::Node { node, .. }, Some(p)) = (*end, at) + && (!doc.contains(node) || doc.page_of(node) != page) + { + *end = End::Point { x: p.x, y: p.y }; + detached = true; + } + } + detached +} + +/// Connector `id` (as `c`) moved by itself (a drag, a nudge: its place changed but not what it +/// joins): its free ends move with it. Whether they did. +fn carry_free_ends(doc: &Document, before: &Document, touched: &BTreeSet, id: NodeId, c: &mut Connector) -> bool { + let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { return false }; + if !touched.contains(&id) || b.transform == n.transform || b.board != n.board { + return false; + } + let Some(expected) = route(doc, c).and_then(|pts| figcraft_geom::points_bounds(pts.iter().copied())).map(|b| b.origin()) else { return false }; + let d = (doc.world_transform(id) * Point::ZERO) - expected; + if d.hypot() <= 1e-9 { + return false; + } + let mut moved = false; + for end in [&mut c.from, &mut c.to] { + if let End::Point { x, y } = end { + (*x, *y) = (*x + d.x, *y + d.y); + moved = true; + } + } + moved +} + +/// Label `label` at the middle of connector `connector`; gone with it when it is gone. +fn place_label(doc: &mut Document, label: NodeId, connector: NodeId) { + let Some(mid) = doc.node(connector).and_then(|_| route_mid(doc, connector)) else { + let _ = doc.remove(label); + return; + }; + let Some(n) = doc.node(label) else { return }; + let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); + let local = pw.inverse() * mid; + let t = Affine::translate((local.x - n.width / 2.0, local.y - n.height / 2.0)); + if n.transform != t + && let Some(n) = doc.node_mut(label) + { + n.transform = t; + } +} + +/// Connector `id`'s first and last points on the page as it is drawn now. +fn ends_now(doc: &Document, id: NodeId) -> Option<(Point, Point)> { + let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; + let w = doc.world_transform(id); + Some((w * network.vertices.first()?.point(), w * network.vertices.last()?.point())) +} + +/// The middle of connector `id`'s route by length, on the page. +fn route_mid(doc: &Document, id: NodeId) -> Option { + let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; + let w = doc.world_transform(id); + let pts: Vec = network.vertices.iter().map(|v| w * v.point()).collect(); + let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum(); + let mut left = total / 2.0; + for s in pts.windows(2) { + let (a, b) = (s[0], s[1]); + let l = (b - a).hypot(); + if l >= left && l > 0.0 { + return Some(a.lerp(b, left / l)); + } + left -= l; + } + pts.first().copied() +} + +/// Write the route `pts` (page points) into connector `id`: its box, place and path. Untouched +/// when it already runs there, so a connector that didn't move isn't an edit. +fn place_route(doc: &mut Document, id: NodeId, pts: &[Point]) { + let Some(n) = doc.node(id) else { return }; + let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); + let inv = pw.inverse(); + let local: Vec = pts.iter().map(|p| inv * *p).collect(); + let Some(b) = figcraft_geom::points_bounds(local.iter().copied()) else { return }; + if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) { + return; + } + let rel: Vec = local.iter().map(|p| Point::new(p.x - b.x0, p.y - b.y0)).collect(); + let network = VectorNetwork::from_points(&rel, false); + let t = Affine::translate((b.x0, b.y0)); + let same = + n.transform == t && n.width == b.width() && n.height == b.height() && matches!(&n.kind, NodeKind::Vector { network: old } if *old == network); + if same { + return; + } + if let Some(n) = doc.node_mut(id) { + n.kind = NodeKind::Vector { network }; + n.transform = t; + (n.width, n.height) = (b.width(), b.height()); + } +} + +/// The page points connector `c` runs through: from the middle of the side of each attached layer +/// facing the other end (or the side it names), straight or in right angles. +pub fn route(doc: &Document, c: &Connector) -> Option> { + let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n)); + let centre = |e: &End, b: Option| match e { + End::Point { x, y } => Some(Point::new(*x, *y)), + End::Node { .. } => b.map(|b| b.center()), + }; + let (fb, tb) = (bounds(&c.from), bounds(&c.to)); + let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?); + let (p0, h0) = attach(&c.from, fb, tc)?; + let (p1, h1) = attach(&c.to, tb, fc)?; + let mut pts = match c.routing { + Routing::Straight => vec![p0, p1], + Routing::Elbow => elbow(p0, h0, p1, h1), + }; + pts.dedup(); + // A turn that doesn't turn (the ends in line) is no point of the route. + let in_line = |i: usize, b: &Point| match (i.checked_sub(1).and_then(|j| pts.get(j)), pts.get(i + 1)) { + (Some(a), Some(c)) => (a.x == b.x && b.x == c.x) || (a.y == b.y && b.y == c.y), + _ => false, + }; + let pts: Vec = pts.iter().enumerate().filter(|(i, b)| !in_line(*i, b)).map(|(_, b)| *b).collect(); + pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts) +} + +/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and +/// whether that side faces sideways (`None`: a free point). +fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option)> { + match e { + End::Point { x, y } => Some((Point::new(*x, *y), None)), + End::Node { side, .. } => { + let b = b?; + let side = match side { + Side::Auto => { + let d = other - b.center(); + // Compare against the box's proportions: a wide box takes the sides more. + if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) { + if d.x >= 0.0 { Side::Right } else { Side::Left } + } else if d.y >= 0.0 { + Side::Bottom + } else { + Side::Top + } + } + s => *s, + }; + Some((side.point(b), Some(side.horizontal()))) + } + } +} + +/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals +/// (`h`: sideways; a free end follows the other). +fn elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { + let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs()); + let h1 = h1.unwrap_or(h0); + match (h0, h1) { + (true, true) => { + let mx = (p0.x + p1.x) / 2.0; + vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1] + } + (false, false) => { + let my = (p0.y + p1.y) / 2.0; + vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1] + } + (true, false) => vec![p0, Point::new(p1.x, p0.y), p1], + (false, true) => vec![p0, Point::new(p0.x, p1.y), p1], + } +} + +// --------------------------------------------------------------------------------------------- +// The board tools. + +impl Session { + /// A press with a board tool: place a sticky, shape or stamp, start a connector or a stroke, + /// or start erasing. + pub(crate) fn board_down(&mut self, ev: &PointerEvent) -> Result<()> { + // A board tool kept while a design file came to the front: back to Move for this press. + if !is_board(self.doc()?) { + self.tool.tool = ToolId::Move; + return self.pointer(ev); + } + // A sticky still being typed in ends first, so the next object is an undo step of its own. + if self.ds()?.text_edit.is_some() { + crate::textedit::end(self)?; + } + match self.tool.tool { + ToolId::Sticky => { + let half = STICKY / 2.0; + let r = self.execute("board.sticky", &json!({"x": ev.x - half, "y": ev.y - half}))?; + self.board_placed(); + if let Some(id) = r.get("id").and_then(id_of) { + edit_text(self, id)?; + } + Ok(()) + } + ToolId::BoardShape => { + let half = SHAPE / 2.0; + self.execute("board.shape", &json!({"x": ev.x - half, "y": ev.y - half}))?; + self.board_placed(); + Ok(()) + } + ToolId::Stamp => { + // Stamps go on one after another: the tool stays. + self.execute("board.stamp", &json!({"x": ev.x, "y": ev.y}))?; + Ok(()) + } + ToolId::Marker | ToolId::Highlighter => { + self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; + Ok(()) + } + ToolId::Connector => { + let from = self.end_at(ev); + self.tool.gesture = Gesture::Connect { from, start: ev.p() }; + Ok(()) + } + ToolId::Eraser => { + self.begin_interaction("Erase")?; + self.tool.gesture = Gesture::Erase { gone: Vec::new() }; + self.erase_at(ev) + } + _ => Ok(()), + } + } + + /// After placing an object: back to the Move tool unless tools stay on. + fn board_placed(&mut self) { + if !self.prefs.keep_tool { + self.set_tool(ToolId::Move); + } + } + + /// What a connector end at the pointer attaches to: the object there, or the point. + fn end_at(&self, ev: &PointerEvent) -> End { + let hit = (|| { + let doc = self.doc().ok()?; + let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?; + let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false); + let n = doc.node(t)?; + (!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t) + })(); + match hit { + Some(node) => End::Node { node, side: Side::Auto }, + None => End::Point { x: ev.x, y: ev.y }, + } + } + + pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) { + let a = match from { + End::Point { x, y } => Point::new(x, y), + End::Node { .. } => start, + }; + self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]); + self.tool.overlay.drop_target = from.node(); + self.tool.gesture = Gesture::Connect { from, start }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + } + + pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> { + self.tool.overlay.rubber = None; + self.tool.overlay.drop_target = None; + // A click, not a drag: nothing to join. + if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { + return Ok(()); + } + let to = self.end_at(ev); + if to.node().is_some() && to.node() == from.node() { + return Ok(()); + } + let json_end = |e: End| match e { + End::Node { node, .. } => json!(node), + End::Point { x, y } => json!({"x": x, "y": y}), + }; + let style = self.tool.board.routing.id(); + self.execute("board.connector", &json!({"from": json_end(from), "to": json_end(to), "style": style}))?; + self.board_placed(); + Ok(()) + } + + /// The eraser at the pointer: the marker and highlighter strokes it touches go (until the + /// gesture ends, which is one undo step). + pub(crate) fn erase_at(&mut self, ev: &PointerEvent) -> Result<()> { + let Gesture::Erase { mut gone } = std::mem::take(&mut self.tool.gesture) else { return Ok(()) }; + let tol = ERASER_PX / ev.zoom.max(1e-6); + self.tool.overlay.brush = Some([ev.x, ev.y, tol]); + let (doc, page) = (self.doc()?.clone(), self.page()?); + if let Some(hit) = + doc.hit_test(page, ev.p(), tol, None).filter(|h| doc.node(*h).is_some_and(|n| n.board == Some(BoardItem::Ink)) && !gone.contains(h)) + { + gone.push(hit); + let ids = gone.clone(); + self.preview(|d, sel| { + for i in &ids { + let _ = d.remove(*i); + } + sel.retain(|s| !ids.contains(s)); + Ok(()) + })?; + } + self.tool.gesture = Gesture::Erase { gone }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + Ok(()) + } + + pub(crate) fn erase_up(&mut self) -> Result<()> { + self.tool.overlay.brush = None; + self.commit_interaction() + } + + /// A finished marker or highlighter stroke (page points). + pub(crate) fn ink_finish(&mut self, path: &str) -> Result<()> { + let high = self.tool.tool == ToolId::Highlighter; + self.execute("board.ink", &json!({"path": path, "highlighter": high, "select": false}))?; + // Strokes come one after another: the selection stays as it was. + Ok(()) + } +} + +impl Session { + /// A double-click with the Move tool on a board object: type in its text (a connector gets a + /// label to type in). True when it was one. + pub(crate) fn board_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId]) -> Result { + let whole = figcraft_doc::geometry::select_target(doc, leaf, sel, false); + let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| owner_of_text(doc, te.id) == Some(whole)); + let Some(item) = doc.node(whole).and_then(|n| n.board.clone()).filter(|_| !editing) else { return Ok(false) }; + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + match item { + BoardItem::Connector(_) => { + let label = match label_of(doc, whole) { + Some(l) => l, + None => { + let r = self.execute("board.label", &json!({"id": whole, "text": "Label"}))?; + r.get("label").and_then(id_of).ok_or_else(|| EngineError::Other("no label made".into()))? + } + }; + edit_text(self, label) + } + b if b.has_text() => edit_text(self, whole), + _ => Ok(false), + } + } +} diff --git a/crates/engine/src/board_tests.rs b/crates/engine/src/board_tests.rs new file mode 100644 index 00000000..5b8cbbf9 --- /dev/null +++ b/crates/engine/src/board_tests.rs @@ -0,0 +1,363 @@ +//! Boards: the file kind, sticky notes, shapes with text, connectors that follow what they join +//! (locally, through undo and in live sessions), stamps, ink and the eraser, and refusals. + +use figcraft_doc::{ + BOARD_PAGE, BoardItem, BoardShape, Connector, End, FileKind, Node, NodeId, NodeKind, Routing, Side, Stamp, StrokeCap, board_color, +}; +use figcraft_geom::Point; +use serde_json::{Value, json}; + +use crate::board::{route, sticky_font_size}; +use crate::collab_live_tests::Room; +use crate::tools::{PointerEvent, PointerKind, ToolId}; +use crate::{EngineError, Session}; + +pub(crate) fn board() -> Session { + let mut s = Session::new(); + s.execute("file.newBoard", &json!({"name": "Ideas"})).unwrap(); + s +} + +pub(crate) fn id(v: &Value, key: &str) -> NodeId { + v[key].as_u64().map(NodeId).unwrap_or_else(|| panic!("no {key} in {v}")) +} + +pub(crate) fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().node(id).unwrap().clone() +} + +pub(crate) fn connector(s: &Session, id: NodeId) -> Connector { + match node(s, id).board { + Some(BoardItem::Connector(c)) => c, + other => panic!("not a connector: {other:?}"), + } +} + +/// Connector `id`'s points on the page. +pub(crate) fn drawn(s: &Session, id: NodeId) -> Vec { + let doc = s.doc().unwrap(); + let NodeKind::Vector { network } = &doc.node(id).unwrap().kind else { panic!("not a vector") }; + let w = doc.world_transform(id); + network.vertices.iter().map(|v| w * v.point()).collect() +} + +pub(crate) fn near(a: Point, b: Point) -> bool { + (a - b).hypot() < 1e-6 +} + +#[test] +fn a_board_is_a_kind_of_file_that_saves_and_reopens() { + let s = board(); + let doc = s.doc().unwrap(); + assert_eq!(doc.kind, FileKind::Board); + assert!(matches!(doc.node(doc.pages[0]).unwrap().kind, NodeKind::Page { background, .. } if background == BOARD_PAGE)); + let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap(); + assert_eq!(back.kind, FileKind::Board); + // Files from before boards (no kind) are design files, and design files don't write one. + let mut d = Session::new(); + d.execute("file.new", &json!({})).unwrap(); + let json = figcraft_format::to_json(d.doc().unwrap()).unwrap(); + assert!(json.get("kind").is_none()); + assert_eq!(figcraft_format::from_json(&json.to_string()).unwrap().0.kind, FileKind::Design); + assert_eq!(crate::inspect::document(&s, 0, false)["kind"], "board", "inspect shows the kind"); +} + +#[test] +fn board_commands_refuse_on_design_files_and_prototyping_refuses_on_boards() { + let mut d = Session::new(); + d.execute("file.new", &json!({})).unwrap(); + for (cmd, p) in [("board.sticky", json!({"x": 0, "y": 0})), ("tool.sticky", json!({})), ("tool.set", json!({"tool": "marker"}))] { + assert!(matches!(d.execute(cmd, &p), Err(EngineError::Disabled(..))), "{cmd} on a design file"); + } + let mut s = board(); + for cmd in ["view.devMode", "view.prototype", "proto.present", "proto.flow.add", "dev.focus"] { + let e = s.execute(cmd, &json!({})).unwrap_err(); + assert!(e.to_string().contains("boards have no prototypes"), "{cmd}: {e}"); + } +} + +#[test] +fn a_sticky_is_a_square_frame_whose_note_shrinks_as_it_grows() { + let mut s = board(); + s.prefs.user_name = "Ada".into(); + let r = s.execute("board.sticky", &json!({"x": 10, "y": 20, "text": "Hi", "color": "blue"})).unwrap(); + let (st, note) = (id(&r, "id"), id(&r, "text")); + let n = node(&s, st); + assert!(n.is_board_atom() && matches!(&n.board, Some(BoardItem::Sticky { author }) if author == "Ada")); + assert_eq!((n.width, n.height), (200.0, 200.0)); + assert_eq!(n.fills[0].solid_color(), board_color("blue")); + assert!(n.layout.is_some() && !n.effects.is_empty()); + assert_eq!(n.children.len(), 2, "the note and its author"); + assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0); + // A click on the note picks the sticky whole. + let doc = s.doc().unwrap(); + assert_eq!(figcraft_doc::geometry::select_target(doc, note, &[], false), st); + // A long note takes a smaller size; a very long one grows the sticky past its square. + let long = "word ".repeat(120); + s.execute("node.set", &json!({"id": note, "props": {"characters": long}})).unwrap(); + assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, sticky_font_size(&long)); + assert!(sticky_font_size(&long) < 16.0); + assert!(node(&s, st).width == 200.0 && node(&s, st).height >= 200.0); + // Undo puts the short note and its size back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0); +} + +#[test] +fn next_sticky_goes_beside_in_the_same_colour_and_emptied_notes_stay() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "color": "pink"})).unwrap(), "id"); + let r = s.execute("board.nextSticky", &json!({})).unwrap(); + let b = id(&r, "id"); + let (na, nb) = (node(&s, a), node(&s, b)); + assert_eq!(nb.transform.translation().x, na.width + 24.0); + assert_eq!(nb.fills, na.fills); + assert_eq!(s.selection(), vec![b]); + // Typing in it and emptying it again keeps its note; ending selects the sticky. + let note = id(&r, "text"); + crate::board::edit_text(&mut s, b).unwrap(); + crate::textedit::insert(&mut s, "x").unwrap(); + crate::textedit::key(&mut s, "Backspace", false, false, false).unwrap(); + crate::textedit::end(&mut s).unwrap(); + assert!(s.doc().unwrap().contains(note)); + assert_eq!(s.selection(), vec![b]); +} + +#[test] +fn shapes_carry_text_and_change_outline() { + let mut s = board(); + let r = s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "text": "Decide", "color": "green"})).unwrap(); + let sh = id(&r, "id"); + let n = node(&s, sh); + assert_eq!(n.board, Some(BoardItem::Shape { shape: BoardShape::Diamond })); + assert_eq!(n.children.len(), 2); + assert!(matches!(node(&s, n.children[0]).kind, NodeKind::Vector { .. })); + s.execute("board.setStyle", &json!({"ids": [sh], "shape": "speechBubble", "fontSize": 24})).unwrap(); + assert_eq!(node(&s, sh).board, Some(BoardItem::Shape { shape: BoardShape::SpeechBubble })); + assert_eq!(node(&s, id(&r, "text")).kind.text().unwrap().style.font_size, 24.0); + s.execute("board.setColor", &json!({"ids": [sh], "color": "#FF0000"})).unwrap(); + assert_eq!(node(&s, n.children[0]).fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into())); + assert!(s.execute("board.shape", &json!({"shape": "blob", "x": 0, "y": 0})).is_err()); +} + +#[test] +fn connectors_follow_what_they_join_and_undo_with_it() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id"); + let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id"); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "then"})).unwrap(), "id"); + let pts = drawn(&s, c); + assert!(near(pts[0], Point::new(200.0, 100.0)) && near(pts[1], Point::new(400.0, 100.0)), "{pts:?}"); + assert_eq!(node(&s, c).stroke.cap_end, StrokeCap::TriangleArrow); + // Its label sits at the middle. + let label = crate::board::label_of(s.doc().unwrap(), c).unwrap(); + let lb = s.doc().unwrap().world_bounds(label).unwrap(); + assert!(near(lb.center(), Point::new(300.0, 100.0)), "{lb:?}"); + // Move one end: the connector re-routes (elbow now, out of the facing sides). + s.execute("board.setStyle", &json!({"ids": [c], "style": "elbow"})).unwrap(); + s.execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 300})).unwrap(); + let pts = drawn(&s, c); + assert!(near(pts[0], Point::new(200.0, 100.0)) && near(*pts.last().unwrap(), Point::new(400.0, 400.0)), "{pts:?}"); + assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles: {pts:?}"); + // Undo takes the move back and the route with it. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0))); + // A deleted end stays where it was, as a point. + s.execute("edit.delete", &json!({"ids": [b]})).unwrap(); + assert!(matches!(connector(&s, c).to, End::Point { .. })); + assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0))); + // A connector moved on its own takes its free end along; the attached one stays. + s.execute("transform.move", &json!({"ids": [c], "dx": 0, "dy": 50})).unwrap(); + assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 400.0 && y == 150.0), "{:?}", connector(&s, c)); + assert!(near(drawn(&s, c)[0], Point::new(200.0, 100.0))); + // Deleting the connector takes its label. + s.execute("edit.delete", &json!({"ids": [c]})).unwrap(); + assert!(!s.doc().unwrap().contains(label)); + assert!(s.execute("board.connector", &json!({"from": a, "to": a})).is_err()); +} + +#[test] +fn routes_are_the_same_for_the_same_document() { + let mut s = board(); + let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0})).unwrap(), "id"); + let c = Connector { from: End::Node { node: a, side: Side::Bottom }, to: End::Point { x: 300.0, y: 500.0 }, routing: Routing::Elbow }; + let doc = s.doc().unwrap(); + let r = route(doc, &c).unwrap(); + assert_eq!(r, route(&doc.clone(), &c).unwrap()); + // Down out of the bottom, across halfway, down into the point. + assert_eq!(r, vec![Point::new(80.0, 160.0), Point::new(80.0, 330.0), Point::new(300.0, 330.0), Point::new(300.0, 500.0)]); +} + +#[test] +fn stamps_and_ink_and_the_eraser() { + let mut s = board(); + s.prefs.user_name = "Grace".into(); + let st = id(&s.execute("board.stamp", &json!({"x": 100, "y": 100, "stamp": "heart"})).unwrap(), "id"); + let n = node(&s, st); + assert_eq!(n.board, Some(BoardItem::Stamp { stamp: Stamp::Heart, author: "Grace".into() })); + assert_eq!(s.doc().unwrap().world_bounds(st).unwrap().center(), Point::new(100.0, 100.0)); + assert!(!n.children.is_empty()); + // Marker and highlighter strokes through the tools; the eraser takes what it touches. + let draw = |s: &mut Session, tool: &str, y: f64| { + s.execute("tool.set", &json!({"tool": tool})).unwrap(); + for (k, x) in [(PointerKind::Down, 0.0), (PointerKind::Move, 50.0), (PointerKind::Move, 100.0), (PointerKind::Up, 150.0)] { + s.pointer(&PointerEvent::at(k, x, y, 1.0)).unwrap(); + } + }; + draw(&mut s, "marker", 300.0); + draw(&mut s, "highlighter", 400.0); + let inks: Vec = s.doc().unwrap().nodes().filter(|n| n.board == Some(BoardItem::Ink)).cloned().collect(); + assert_eq!(inks.len(), 2); + assert!(inks.iter().any(|n| n.opacity < 1.0 && n.stroke.weight > 10.0), "a highlighter stroke"); + let undo = s.undo_depth().0; + s.execute("tool.eraser", &json!({})).unwrap(); + for (k, x) in [(PointerKind::Down, 20.0), (PointerKind::Move, 60.0), (PointerKind::Up, 80.0)] { + s.pointer(&PointerEvent::at(k, x, 300.0, 1.0)).unwrap(); + } + assert_eq!(s.doc().unwrap().nodes().filter(|n| n.board == Some(BoardItem::Ink)).count(), 1, "the marker stroke is gone"); + assert_eq!(s.undo_depth().0, undo + 1, "one step"); + assert_eq!(s.tool.tool, ToolId::Eraser); +} + +#[test] +fn the_sticky_and_connector_tools() { + let mut s = board(); + s.execute("tool.sticky", &json!({"color": "orange"})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, 1.0)).unwrap(); + let te = s.ds().unwrap().text_edit.clone().expect("typing in the new sticky"); + let a = crate::board::owner_of_text(s.doc().unwrap(), te.id).unwrap(); + assert_eq!(node(&s, a).fills[0].solid_color(), board_color("orange")); + crate::textedit::insert(&mut s, "First").unwrap(); + crate::textedit::end(&mut s).unwrap(); + let b = id(&s.execute("board.sticky", &json!({"x": 500, "y": 0})).unwrap(), "id"); + s.execute("tool.connector", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 300.0, 100.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 600.0, 100.0, 1.0)).unwrap(); + let c = s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).map(|n| n.id).unwrap(); + let k = connector(&s, c); + assert_eq!((k.from.node(), k.to.node()), (Some(a), Some(b))); +} + +#[test] +fn boards_sync_in_live_sessions() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "A"})).unwrap(), "id"); + let mut room = Room::new(s.doc().unwrap().clone(), 2, 7); + assert_eq!(room.s(1).doc().unwrap().kind, FileKind::Board, "joiners get a board"); + let b = id(&room.s(1).execute("board.sticky", &json!({"x": 400, "y": 0, "text": "B"})).unwrap(), "id"); + room.settle(); + let c = id(&room.s(0).execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id"); + room.s(1).execute("board.stamp", &json!({"x": 50, "y": 50, "stamp": "star"})).unwrap(); + room.settle(); + room.check(); + // Person 1 moves the sticky person 0 connected: both see the connector follow it. + room.s(1).execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 250})).unwrap(); + room.s(0).execute("board.setColor", &json!({"ids": [a], "color": "violet"})).unwrap(); + room.settle(); + room.check(); + let doc = room.server.document(); + let NodeKind::Vector { network } = &doc.node(c).unwrap().kind else { panic!("not a vector") }; + let end = doc.world_transform(c) * network.vertices.last().unwrap().point(); + assert_eq!(end, Point::new(400.0, 350.0), "to the moved sticky's facing side"); + assert_eq!(doc.node(a).unwrap().fills[0].solid_color(), board_color("violet")); +} + +#[test] +fn board_commands_survive_junk() { + let junk = [ + json!({}), + json!(null), + json!([1]), + json!({"x": "NaN", "y": 1e308, "text": 5, "color": [1], "author": {"x": 1}, "parent": "p"}), + json!({"x": -1e308, "y": 0, "color": "neon", "shape": 7, "width": -5, "height": 1e308, "stamp": "skull"}), + json!({"from": {"x": "a"}, "to": 99_999, "fromSide": "inside", "style": 3, "startCap": "spike", "endCap": 7, "label": [1]}), + json!({"from": {"x": 1e308, "y": -1e308}, "to": {"x": 0, "y": 0}, "style": "elbow", "label": "\u{0}"}), + json!({"ids": ["x", -1, 99_999], "color": "#GGG", "fontSize": -1, "side": "up", "edit": "yes"}), + json!({"id": 2, "text": "", "path": "M0 0 L", "points": [[1]], "highlighter": "yes"}), + json!({"path": "M0 0 L 1e308 1e308", "points": [[0, 0], [1e308, 1]]}), + ]; + let ids: Vec<&str> = crate::cmd::all_commands() + .iter() + .map(|c| c.id) + .filter(|i| { + i.starts_with("board.") + || ["tool.sticky", "tool.shape", "tool.connector", "tool.marker", "tool.highlighter", "tool.eraser", "tool.stamp", "file.newBoard"] + .contains(i) + }) + .collect(); + assert!(ids.len() >= 17, "{ids:?}"); + for cmd in ids { + for j in &junk { + let mut s = board(); + let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone(); + let b = s.execute("board.shape", &json!({"x": 300, "y": 0})).unwrap()["id"].clone(); + let _ = s.execute("board.connector", &json!({"from": a, "to": b, "label": "x"})); + if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + } + } + // Every other command on a board with objects on it. + for spec in crate::cmd::all_commands() { + if spec.id.starts_with("file.") && spec.id != "file.newBoard" || spec.id == "text.rescanFonts" || spec.id == "image.place" { + continue; + } + for j in &junk[..3] { + let mut s = board(); + let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone(); + let b = s.execute("board.stamp", &json!({"x": 300, "y": 0})).unwrap()["id"].clone(); + let _ = s.execute("board.connector", &json!({"from": a, "to": b})); + if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + } + } +} + +#[test] +fn a_press_on_a_stickys_background_picks_and_drags_it() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "Hi"})).unwrap(), "id"); + let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id"); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id"); + s.execute("select.none", &json!({})).unwrap(); + // Below the note, on the sticky's own fill (a top-level frame's background is the canvas). + s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 150.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 250.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 450.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 450.0, 1.0)).unwrap(); + assert_eq!(s.selection(), vec![a]); + assert_eq!(node(&s, a).transform.translation().y, 300.0); + assert!(near(drawn(&s, c)[0], Point::new(200.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c)); +} + +#[test] +fn a_copy_of_joined_objects_joins_the_copies() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id"); + let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id"); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "label": "x"})).unwrap(), "id"); + let label = crate::board::label_of(s.doc().unwrap(), c).unwrap(); + s.execute("edit.duplicate", &json!({"ids": [a, b, c, label]})).unwrap(); + let copies = s.selection(); + assert_eq!(copies.len(), 4, "{copies:?}"); + let k = copies.iter().find_map(|i| match node(&s, *i).board { + Some(BoardItem::Connector(k)) => Some(k), + _ => None, + }); + let k = k.unwrap(); + assert!(copies.contains(&k.from.node().unwrap()) && copies.contains(&k.to.node().unwrap()), "{k:?} {copies:?}"); + // Copy and paste too. + s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let pasted = s.selection(); + let k = pasted.iter().find_map(|i| match node(&s, *i).board { + Some(BoardItem::Connector(k)) => Some(k), + _ => None, + }); + let k = k.unwrap(); + assert!(pasted.contains(&k.from.node().unwrap()) && pasted.contains(&k.to.node().unwrap()), "{k:?} {pasted:?}"); +} diff --git a/crates/engine/src/board_tools_tests.rs b/crates/engine/src/board_tools_tests.rs new file mode 100644 index 00000000..4109adda --- /dev/null +++ b/crates/engine/src/board_tools_tests.rs @@ -0,0 +1,277 @@ +//! Boards, continued: copies and pastes (within a file and from another), routes with named sides +//! and free points, connectors inside moving sections, styles and labels with their refusals, and +//! the shape, stamp, highlighter and connector tools with double-clicks. + +use figcraft_doc::{BoardItem, BoardShape, End, NodeId, Side, Stamp, StrokeCap, board_color}; +use figcraft_geom::Point; +use serde_json::json; + +use crate::Session; +use crate::board::route; +use crate::board_tests::{board, connector, drawn, id, near, node}; +use crate::tools::{PointerEvent, PointerKind, ToolId}; + +/// The connector among `ids`, if any. +fn connector_in(s: &Session, ids: &[NodeId]) -> Option { + let doc = s.doc().ok()?; + ids.iter().find_map(|i| match &doc.node(*i)?.board { + Some(BoardItem::Connector(k)) => Some(*k), + _ => None, + }) +} + +#[test] +fn pasting_into_another_board_detaches_what_was_left_behind() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id"); + let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id"); + let r = s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "x"})).unwrap(); + let (c, label) = (id(&r, "id"), id(&r, "label")); + let first = s.active; + // A second board whose own ids are the same small numbers as the first's. + let second = s.execute("file.newBoard", &json!({})).unwrap()["index"].clone(); + for x in [0, 300, 600, 900] { + s.execute("board.sticky", &json!({"x": x, "y": 500})).unwrap(); + } + let others: Vec = s.doc().unwrap().nodes().map(|n| n.id).collect(); + s.execute("doc.switch", &json!({"index": first})).unwrap(); + s.execute("select.set", &json!({"ids": [a, c, label]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("doc.switch", &json!({"index": second})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let doc = s.doc().unwrap(); + let pasted = doc.nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_))) && !others.contains(&n.id)).unwrap().clone(); + let k = connector(&s, pasted.id); + let from = k.from.node().unwrap(); + assert!(!others.contains(&from) && matches!(doc.node(from).unwrap().board, Some(BoardItem::Sticky { .. })), "joins the pasted sticky"); + let to = drawn(&s, pasted.id).last().copied().unwrap(); + assert!(matches!(k.to, End::Point { x, y } if near(Point::new(x, y), to)), "the end left behind stays where it is drawn: {k:?}"); + let pasted_label = crate::board::label_of(doc, pasted.id).expect("the label came with its connector"); + assert!(!others.contains(&pasted_label)); + // A label copied without its connector doesn't come. + s.execute("doc.switch", &json!({"index": first})).unwrap(); + s.execute("select.set", &json!({"ids": [label]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("doc.switch", &json!({"index": second})).unwrap(); + let labels = |s: &Session| s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Label { .. }))).count(); + let before = labels(&s); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(labels(&s), before); + // Within its own file, a connector pasted alone stays joined to the same layers. + s.execute("doc.switch", &json!({"index": first})).unwrap(); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let k = connector_in(&s, &s.selection()).unwrap(); + assert_eq!((k.from.node(), k.to.node()), (Some(a), Some(b))); +} + +#[test] +fn duplicating_and_alt_dragging_keep_connections_inside_copied_sections() { + let mut s = board(); + let sec = id(&s.execute("node.create", &json!({"type": "section", "x": -50, "y": -50, "width": 400, "height": 400})).unwrap(), "id"); + let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id"); + let b = id(&s.execute("board.sticky", &json!({"x": 600, "y": 0})).unwrap(), "id"); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id"); + s.execute("edit.duplicate", &json!({"ids": [sec, c]})).unwrap(); + let copies = s.selection(); + let from = connector_in(&s, &copies).unwrap().from.node().unwrap(); + let doc = s.doc().unwrap(); + assert!(from != a && copies.iter().any(|r| doc.is_ancestor(*r, from)), "the copy joins the sticky inside the copied section"); + // Alt-drag copies the same way. + s.execute("select.set", &json!({"ids": [b, c]})).unwrap(); + let press = |k, x| PointerEvent { alt: true, ..PointerEvent::at(k, x, 100.0, 1.0) }; + for (k, x) in [(PointerKind::Down, 700.0), (PointerKind::Move, 760.0), (PointerKind::Move, 800.0), (PointerKind::Up, 800.0)] { + s.pointer(&press(k, x)).unwrap(); + } + let copies = s.selection(); + assert_eq!(copies.len(), 2); + let k = connector_in(&s, &copies).unwrap(); + assert!(copies.contains(&k.to.node().unwrap()), "{k:?}"); +} + +#[test] +fn routes_take_named_sides_and_free_points() { + let mut s = board(); + let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(), "id"); + let b = id(&s.execute("board.shape", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap(), "id"); + let doc = s.doc().unwrap(); + let elbow = |from, to| route(doc, &figcraft_doc::Connector { from, to, routing: figcraft_doc::Routing::Elbow }).unwrap(); + let side = |n, side| End::Node { node: n, side }; + let p = |x, y| End::Point { x, y }; + let pt = Point::new; + // Side to side: out, across halfway, in. + assert_eq!(elbow(side(a, Side::Right), side(b, Side::Left)), vec![pt(100.0, 50.0), pt(200.0, 50.0), pt(200.0, 350.0), pt(300.0, 350.0)]); + // A side and a top or bottom: one turn. + assert_eq!(elbow(side(a, Side::Right), side(b, Side::Top)), vec![pt(100.0, 50.0), pt(350.0, 50.0), pt(350.0, 300.0)]); + assert_eq!(elbow(side(a, Side::Bottom), side(b, Side::Left)), vec![pt(50.0, 100.0), pt(50.0, 350.0), pt(300.0, 350.0)]); + // Two free points: the longer way first; in a line, no turns. + assert_eq!(elbow(p(0.0, 0.0), p(100.0, 40.0)), vec![pt(0.0, 0.0), pt(50.0, 0.0), pt(50.0, 40.0), pt(100.0, 40.0)]); + assert_eq!(elbow(p(0.0, 0.0), p(100.0, 0.0)), vec![pt(0.0, 0.0), pt(100.0, 0.0)]); + // An automatic side faces the other end. + let straight = route(doc, &figcraft_doc::Connector { from: side(a, Side::Auto), to: p(50.0, 500.0), routing: figcraft_doc::Routing::Straight }); + assert_eq!(straight.unwrap(), vec![pt(50.0, 100.0), pt(50.0, 500.0)]); + assert_eq!(Side::from_id("TOP"), Some(Side::Top)); + assert_eq!(Side::from_id("inside"), None); + // The same place twice is refused, as are a side that doesn't exist and a point without numbers. + assert!(s.execute("board.connector", &json!({"from": {"x": 5, "y": 5}, "to": {"x": 5, "y": 5}})).is_err()); + assert!(s.execute("board.connector", &json!({"from": a, "to": b, "fromSide": "inside"})).is_err()); + assert!(s.execute("board.connector", &json!({"from": a, "to": {"x": "a"}})).is_err()); + assert!(s.execute("board.connector", &json!({"from": a})).is_err()); +} + +#[test] +fn a_connector_follows_a_layer_whose_section_moves_and_ignores_unrelated_edits() { + let mut s = board(); + let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 300})).unwrap(), "id"); + let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id"); + let b = id(&s.execute("board.stamp", &json!({"x": 800, "y": 150})).unwrap(), "id"); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(), "id"); + assert!(near(drawn(&s, c)[0], Point::new(250.0, 150.0))); + // Only the section is edited; the sticky inside it moves with it, and so does the connector. + s.execute("transform.move", &json!({"ids": [sec], "dx": 0, "dy": 100})).unwrap(); + assert!(near(drawn(&s, c)[0], Point::new(250.0, 250.0)), "{:?}", drawn(&s, c)); + // An edit that touches neither end leaves the connector as it was. + let before = s.doc().unwrap().node_arc(c).cloned().unwrap(); + s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, s.doc().unwrap().node_arc(c).unwrap())); +} + +#[test] +fn styles_labels_and_their_refusals() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "author": false})).unwrap(), "id"); + assert_eq!(node(&s, a).children.len(), 1, "no author line"); + let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0, "author": " Lin "})).unwrap(), "id"); + assert!(matches!(node(&s, b).board, Some(BoardItem::Sticky { ref author }) if author == "Lin")); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "startCap": "circleFilled", "endCap": "none"})).unwrap(), "id"); + assert_eq!((node(&s, c).stroke.cap_start, node(&s, c).stroke.cap_end), (StrokeCap::CircleFilled, StrokeCap::None)); + // Labels: set, change, size, remove; named by the label itself too. + let l = id(&s.execute("board.label", &json!({"id": c, "text": "first"})).unwrap(), "label"); + assert_eq!(id(&s.execute("board.label", &json!({"id": l, "text": "second"})).unwrap(), "label"), l, "the same label, new text"); + let text = crate::board::text_of(s.doc().unwrap(), l).unwrap(); + assert_eq!(node(&s, text).kind.text().unwrap().characters, "second"); + s.execute("board.setStyle", &json!({"ids": [l], "fontSize": 36})).unwrap(); + assert_eq!(node(&s, text).kind.text().unwrap().style.font_size, 36.0); + assert!(s.execute("board.label", &json!({"id": c, "text": ""})).unwrap()["label"].is_null()); + assert!(!s.doc().unwrap().contains(l)); + // What doesn't take a style or colour says so. + assert!(s.execute("board.setStyle", &json!({"ids": [a], "fontSize": 30})).is_err(), "stickies size their own text"); + assert!(s.execute("board.setStyle", &json!({"ids": [c], "fontSize": 0})).is_err()); + let st = id(&s.execute("board.stamp", &json!({"x": 0, "y": 0, "stamp": "check"})).unwrap(), "id"); + assert!(s.execute("board.setColor", &json!({"ids": [st], "color": "blue"})).is_err()); + assert!(s.execute("board.setColor", &json!({"ids": [a]})).is_err()); + assert!(s.execute("board.label", &json!({"id": a, "text": "x"})).is_err()); + // A palette colour on a line draws in its deeper shade; a hex colour as given. + s.execute("board.setColor", &json!({"ids": [c], "color": "blue"})).unwrap(); + assert!(node(&s, c).strokes[0].solid_color().is_some() && node(&s, c).strokes[0].solid_color() != board_color("blue")); + s.execute("board.setColor", &json!({"ids": [c], "color": "#123456"})).unwrap(); + assert_eq!(node(&s, c).strokes[0].solid_color().map(|c| c.to_hex()), Some("123456".into())); + // Next stickies on each side, and refusals. + for (side, dx, dy) in [("below", 0.0, 224.0), ("left", -224.0, 0.0), ("above", 0.0, -224.0)] { + let n = id(&s.execute("board.nextSticky", &json!({"id": a, "side": side})).unwrap(), "id"); + let t = node(&s, n).transform.translation(); + assert_eq!((t.x, t.y), (dx, dy), "{side}"); + } + assert!(s.execute("board.nextSticky", &json!({"id": a, "side": "diagonal"})).is_err()); + assert!(s.execute("board.nextSticky", &json!({"id": c})).is_err()); + assert!(s.execute("board.ink", &json!({})).is_err()); + assert!(s.execute("board.sticky", &json!({"x": 0, "y": 0, "parent": c})).is_err(), "a connector holds no layers"); +} + +#[test] +fn the_shape_stamp_highlighter_and_connector_tools_and_double_clicks() { + let mut s = board(); + let click = |s: &mut Session, x, y| { + s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap(); + }; + s.execute("tool.shape", &json!({"shape": "roundedRectangle"})).unwrap(); + click(&mut s, 100.0, 100.0); + let sh = s.selection()[0]; + assert_eq!(node(&s, sh).board, Some(BoardItem::Shape { shape: BoardShape::RoundedRectangle })); + assert_eq!(s.tool.tool, ToolId::Move, "back to Move after placing"); + s.execute("tool.stamp", &json!({"stamp": "question"})).unwrap(); + click(&mut s, 400.0, 100.0); + click(&mut s, 450.0, 100.0); + let stamps = s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Stamp { stamp: Stamp::Question, .. }))).count(); + assert_eq!(stamps, 2); + assert_eq!(s.tool.tool, ToolId::Stamp, "stamps go on one after another"); + assert!(s.execute("tool.stamp", &json!({"stamp": "skull"})).is_err()); + s.execute("tool.highlighter", &json!({"color": "pink"})).unwrap(); + assert_eq!(Some(s.tool.board.highlighter), board_color("pink")); + s.execute("tool.marker", &json!({"color": "#336699"})).unwrap(); + assert_eq!(s.tool.board.marker.to_hex(), "336699"); + // A connector drag that is only a click, or from an object to itself, makes nothing. + s.execute("tool.connector", &json!({"style": "straight"})).unwrap(); + click(&mut s, 100.0, 100.0); + s.pointer(&PointerEvent::at(PointerKind::Down, 60.0, 120.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 140.0, 1.0)).unwrap(); + assert!(s.tool.overlay.rubber.is_some(), "the rubber band shows while dragging"); + s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 140.0, 1.0)).unwrap(); + assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_))))); + // From the shape out to empty canvas: a connector to a free point. + s.pointer(&PointerEvent::at(PointerKind::Down, 80.0, 140.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 80.0, 600.0, 1.0)).unwrap(); + let c = s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).map(|n| n.id).unwrap(); + assert!(matches!(connector(&s, c).to, End::Point { .. })); + // Double-clicks: on the shape its text, on the connector a new label, both typed into. + s.execute("tool.move", &json!({})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 80.0, 140.0, 1.0)).unwrap(); + let te = s.ds().unwrap().text_edit.clone().expect("typing in the shape"); + assert_eq!(crate::board::owner_of_text(s.doc().unwrap(), te.id), Some(sh)); + crate::textedit::end(&mut s).unwrap(); + let line = drawn(&s, c); + let m = line[0].midpoint(line[1]); + s.pointer(&PointerEvent::at(PointerKind::DoubleClick, m.x, m.y, 1.0)).unwrap(); + let label = crate::board::label_of(s.doc().unwrap(), c).expect("a label was added"); + let te = s.ds().unwrap().text_edit.clone().expect("typing in the label"); + assert_eq!(crate::board::owner_of_text(s.doc().unwrap(), te.id), Some(label)); +} + +#[test] +fn an_ink_stroke_ends_where_the_button_comes_up() { + let mut s = board(); + s.execute("tool.marker", &json!({})).unwrap(); + // Down and up with no move between: the stroke still runs from the press to the release. + s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 150.0, 0.0, 1.0)).unwrap(); + let doc = s.doc().unwrap(); + let ink = doc.nodes().find(|n| matches!(n.board, Some(BoardItem::Ink))).map(|n| n.id).expect("a stroke"); + let b = doc.world_bounds(ink).unwrap(); + assert!(b.x0.abs() < 6.0 && (b.x1 - 150.0).abs() < 6.0, "from 0 to 150 (stroke width aside): {b:?}"); +} + +#[test] +fn a_second_sticky_ends_the_first_ones_typing_and_undoes_alone() { + let mut s = board(); + s.execute("tool.sticky", &json!({})).unwrap(); + s.prefs.keep_tool = true; + s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, 1.0)).unwrap(); + let text = s.ds().unwrap().text_edit.as_ref().expect("typing in the new sticky").id; + crate::textedit::insert(&mut s, "first note").unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 500.0, 100.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 500.0, 100.0, 1.0)).unwrap(); + let stickies = |s: &Session| s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Sticky { .. }))).count(); + let typed = |s: &Session| s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters.clone(); + assert_eq!(stickies(&s), 2); + crate::textedit::end(&mut s).unwrap(); + s.undo().unwrap(); + assert_eq!(stickies(&s), 1, "the second sticky is its own undo step"); + assert_eq!(typed(&s), "first note", "the first sticky keeps what was typed in it"); +} + +#[test] +fn a_board_tool_left_on_presses_as_move_in_a_design_file() { + let mut s = board(); + s.execute("tool.sticky", &json!({})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(), "id"); + s.execute("select.none", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 50.0, 50.0, 1.0)).unwrap(); + assert_eq!(s.tool.tool, ToolId::Move); + assert_eq!(s.selection(), vec![r], "the press selected as Move does"); +} diff --git a/crates/engine/src/bound_alpha_tests.rs b/crates/engine/src/bound_alpha_tests.rs index b7210f0f..fb15425d 100644 --- a/crates/engine/src/bound_alpha_tests.rs +++ b/crates/engine/src/bound_alpha_tests.rs @@ -129,3 +129,36 @@ fn code_gives_a_translucent_stroke_its_alpha_once() { let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string(); assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}"); } + +#[test] +fn binding_keeps_an_opacity_set_on_the_paint() { + // A 15% tint whose colour comes from a variable (#923): the bind replaces the colour only. + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light"]})); + ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "red", "type": "color", "value": "#FF0000"})); + let props = json!({"fills": [{"type": "solid", "color": "#000000", "opacity": 0.15}]}); + let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, "props": props})).unwrap(); + let r = NodeId(r["id"].as_u64().unwrap()); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "red"})); + let p = s.doc().unwrap().node(r).unwrap().fills[0].clone(); + assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0xFF, 0, 0) }); + assert!(near(p.opacity, 0.15), "{}", p.opacity); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null})); + assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.15)); + // An opacity set on a `#RRGGBBAA` fill is the user's too, and stays. + let (mut s, r) = setup("#FF0000", "#FF0000"); + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.25})); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert!(near(alpha(&s, r), 0.25), "{}", alpha(&s, r)); +} + +#[test] +fn an_unbind_and_rebind_keeps_the_opacity_set_on_the_paint() { + // A paint at 50% bound to a half-transparent colour shows a quarter, unbound or bound again. + let (mut s, r) = setup("#FF000080", "#FF000080"); + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5})); + for variable in [json!("tint"), json!(null), json!("tint"), json!(null)] { + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": variable})); + assert!(near(alpha(&s, r), HALF / 2.0), "{variable}: {}", alpha(&s, r)); + } +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 4b6cd3fc..5180b340 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -21,8 +21,9 @@ pub const SEP: &str = "-"; pub static MENU: &[MenuItem] = &[ // File m!("File", "New design file", "file.new"), - // FigCraft's own: starter templates (not counted by `parity`, like the other items of its own - // listed in `OWN`). + m!("File", "New board", "file.newBoard"), + // FigCraft's own: boards and starter templates (not counted by `parity`, like the other items + // of its own listed in `OWN`). m!("File", "New from template…", "file.newFromTemplate"), m!("File", "New window", "window.new"), m!("File", "Open…", "file.open"), @@ -289,7 +290,7 @@ pub fn items() -> impl Iterator { } /// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones). -const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"]; +const OWN: [&str; 4] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts"]; /// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer. pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 6a5b1012..78609771 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -165,7 +165,7 @@ pub fn specs() -> Vec { spec!( "component.resetOverride", "Reset override", - "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|component|props|slot; all when omitted)}", + "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|modes|component|props|slot; all when omitted)}", true, reset_override ), @@ -786,6 +786,38 @@ fn push_changes(s: &mut Session, p: &Value) -> Result { Ok(json!({"layers": pushed})) } +fn restore_prop_defs(doc: &Document, props: &BTreeMap) -> BTreeMap { + let mut defs = BTreeMap::new(); + for (k, v) in props { + let def = match v { + Value::Bool(b) => PropDef::Boolean { default: *b }, + Value::String(t) => PropDef::Text { default: t.clone() }, + other => match id_of(other) { + Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() }, + _ => continue, + }, + }; + defs.insert(k.clone(), def); + } + defs +} + +fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap { + let mut map: BTreeMap = BTreeMap::new(); + let mut used: BTreeSet = BTreeSet::new(); + map.insert(i, component); + used.insert(component); + for d in sub.iter().filter(|d| **d != i) { + let src = doc.node(*d).and_then(|n| n.source); + let nid = match src { + Some(s) if !doc.contains(s) && used.insert(s) => s, + _ => doc.alloc_id(), + }; + map.insert(*d, nid); + } + map +} + fn restore_main(s: &mut Session, p: &Value) -> Result { let cmd = "component.restoreMain"; let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; @@ -804,35 +836,13 @@ fn restore_main(s: &mut Session, p: &Value) -> Result { // The main comes back with its old id (other instances point to it) and its layers with the // ids their copies mirror, so every instance re-links by source. let sub = doc.subtree(i); - let mut map: BTreeMap = BTreeMap::new(); - let mut used: BTreeSet = BTreeSet::new(); - map.insert(i, component); - used.insert(component); - for d in sub.iter().filter(|d| **d != i) { - let src = doc.node(*d).and_then(|n| n.source); - let nid = match src { - Some(s) if !doc.contains(s) && used.insert(s) => s, - _ => doc.alloc_id(), - }; - map.insert(*d, nid); - } + let map = restore_id_map(doc, i, component, &sub); let mut nodes = Vec::new(); let mut nested = Vec::new(); for d in &sub { let Some(mut n) = doc.node(*d).cloned() else { continue }; if *d == i { - let mut defs = BTreeMap::new(); - for (k, v) in &props { - let def = match v { - Value::Bool(b) => PropDef::Boolean { default: *b }, - Value::String(t) => PropDef::Text { default: t.clone() }, - other => match id_of(other) { - Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() }, - _ => continue, - }, - }; - defs.insert(k.clone(), def); - } + let defs = restore_prop_defs(doc, &props); n.kind = NodeKind::Component { description: String::new(), props: defs }; n.source = None; n.overrides.clear(); diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 3a9f6d67..33c611a9 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -111,7 +111,8 @@ pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) -> return None; } let n = doc.node(id)?; - if !n.visible || n.locked { + // Board objects (stickies, shapes with text) never take layers in. + if !n.visible || n.locked || n.is_board_atom() { return None; } let is_container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..5ed92aec 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -494,6 +494,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { } out.push(nid); } + // Board connectors copied with what they join join the copies. + let map = crate::board::copy_map(doc, &ids, &out); + crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false); s.set_selection(out.clone()); s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() }); Ok(json!({"ids": out})) @@ -675,6 +678,10 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?); all.extend(map); } + // Board connectors pasted with what they join join the pasted copies. + let originals: Vec<(NodeId, figcraft_doc::BoardItem)> = + clip.nodes.iter().flat_map(|(_, nodes)| nodes).filter_map(|n| Some((*all.get(&n.id)?, n.board.clone()?))).collect(); + crate::board::rejoin(doc, &originals, &all, !copy_local); if let Some(src) = foreign { crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?; } @@ -691,7 +698,10 @@ fn paste(s: &mut Session, p: &Value) -> Result { let target = id(p, "parent").or_else(|| { sel.first() .copied() - .filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }))) + .filter(|i| { + sel.len() == 1 + && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }) && !n.is_board_atom()) + }) .filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i))) }); let parent = target.unwrap_or(page); @@ -1365,10 +1375,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) }; if c.a < 1.0 { slot.opacity = c.a; + slot.color_alpha = c.a; } } if let Some(o) = num(p, "opacity") { slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32; + slot.color_alpha = 1.0; } if let Some(v) = boolean(p, "visible") { slot.visible = v; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7380711f..ec579d51 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -11,22 +11,34 @@ use super::{CommandSpec, ok}; use crate::params::*; use crate::{DocState, EngineError, Result, Session, bad, spec}; +/// A new file of `kind` in a tab of its own. +fn new_file(s: &mut Session, p: &Value, kind: FileKind) -> Result { + let name = string(p, "name").unwrap_or_else(|| if kind == FileKind::Board { "Untitled board" } else { "Untitled" }.into()); + let mut doc = Document::new(name); + doc.kind = kind; + // A property of the page from here on: a later change of theme leaves it alone. Boards are + // light whatever the theme. + let background = if kind == FileKind::Board { BOARD_PAGE } else { s.new_page_background() }; + for page in doc.pages.clone() { + if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) { + *b = background; + } + } + s.docs.push(DocState::new(doc, None)); + s.active = s.docs.len() - 1; + Ok(json!({"index": s.active, "kind": kind.id()})) +} + pub fn specs() -> Vec { vec![ - spec!("file.new", "New design file", "{name?}", false, |s, p| { - let name = string(p, "name").unwrap_or_else(|| "Untitled".into()); - let mut doc = Document::new(name); - // A property of the page from here on: a later change of theme leaves it alone. - let background = s.new_page_background(); - for page in doc.pages.clone() { - if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) { - *b = background; - } - } - s.docs.push(DocState::new(doc, None)); - s.active = s.docs.len() - 1; - Ok(json!({"index": s.active})) - }), + spec!("file.new", "New design file", "{name?}", false, |s, p| new_file(s, p, FileKind::Design)), + spec!( + "file.newBoard", + "New board", + "{name?} a board: sticky notes, shapes with text, connectors, marker and stamps on a light page", + false, + |s, p| new_file(s, p, FileKind::Board) + ), spec!( "file.open", "Open", @@ -366,7 +378,7 @@ fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result { if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) } } -fn read_version(v: &Version) -> Result> { +pub(crate) fn read_version(v: &Version) -> Result> { v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name))) } @@ -476,11 +488,11 @@ pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> { } fn ext(path: &str) -> String { - std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() + std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase() } fn file_stem(path: &str) -> String { - std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string() + std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Untitled").to_string() } /// The counts the import report shows for a freshly loaded document. @@ -1169,8 +1181,11 @@ fn export_path(name: &str) -> String { if parts.is_empty() { "Untitled".into() } else { parts.join("/") } } -/// `name` without path separators and characters file systems reject. -fn safe_file_name(name: &str) -> String { +/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and +/// control characters become `_`); everything else is kept, up to the first 200 characters. +/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before +/// the number and extension it adds). +pub fn safe_file_name(name: &str) -> String { name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect() } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f011238d..60d08963 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -109,6 +109,7 @@ fn registry() -> &'static Registry { list.extend(crate::online::specs()); list.extend(crate::invite_links::specs()); list.extend(crate::templates::specs()); + list.extend(crate::board::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { if index.insert(s.id, i).is_some() { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index f180b53c..2eedab45 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st /// Keys `node.set` understands (documented for agents). pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \ -fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ +fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ @@ -372,14 +372,21 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec) { } } -/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable -/// bindings last so paints set in the same call don't detach them. +/// `"none"` (either case) and null ask for no paint, as an empty `fills`/`strokes` list does. +fn paint_none(v: &Value) -> bool { + v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none")) +} + +/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style +/// links after the values so a link wins over values its style sets, variable bindings last so +/// paints set in the same call don't detach them. fn apply_order(k: &str) -> u8 { match k { "width" | "height" | "w" | "h" => 0, "rotation" => 2, "x" | "y" => 3, - "boundVariables" => 4, + "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4, + "boundVariables" => 5, _ => 1, } } @@ -462,13 +469,21 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?), "fill" => { - let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?; - set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); + if paint_none(v) { + set_paints(doc.get_mut(id)?, true, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); + } } "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?), "stroke" => { - let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?; - set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); + if paint_none(v) { + set_paints(doc.get_mut(id)?, false, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); + } } "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, @@ -533,8 +548,12 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map Vec { spec!( "dev.compare", "Compare changes", - "{id? (default the first selected layer), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked (`snapshot` is false when that isn't known: a file from before snapshots were kept)", + "{id? (default the first selected layer), version? (a version id from version.list: compare with that saved version instead; any top-level frame or section), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, version: {id, name, time} | null, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked, or since the version (`snapshot` is false when that isn't known: a file from before snapshots were kept)", false, compare ), @@ -104,14 +104,30 @@ fn resolve_change(s: &mut Session, p: &Value) -> Result { fn compare(s: &mut Session, p: &Value) -> Result { let cmd = "dev.compare"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame or section or pass `id`"))?; - let doc = s.doc()?; + let version = match get(p, "version") { + None | Some(Value::Null) => None, + Some(Value::String(v)) => Some(v.clone()), + Some(_) => return Err(bad(cmd, "version: a version id (version.list)")), + }; + let ds = s.ds()?; + let doc = &ds.doc; let n = doc.node(id).ok_or_else(|| bad(cmd, "no such layer"))?; - if !has_status(doc, id) || n.dev_status != DevStatus::ReadyForDev { - return Err(bad(cmd, "compare a frame or section marked ready for dev")); + if !has_status(doc, id) { + return Err(bad(cmd, "compare a top-level frame or a section")); } - let v = serde_json::to_value(crate::devcompare::compare(doc, id)).unwrap_or(Value::Null); + let (cmp, against) = match &version { + Some(v) => { + let ver = ds.versions.iter().find(|x| x.id == *v).ok_or_else(|| bad(cmd, format!("no version `{v}`")))?; + let before = crate::cmd::file::read_version(ver)?; + (crate::devcompare::compare_with(&before, doc, id), json!({"id": ver.id, "name": ver.name, "time": ver.time})) + } + None if n.dev_status != DevStatus::ReadyForDev => return Err(bad(cmd, "compare a frame or section marked ready for dev, or pass `version`")), + None => (crate::devcompare::compare(doc, id), Value::Null), + }; + let mut v = serde_json::to_value(cmp).unwrap_or(Value::Null); + v["version"] = against; if boolean(p, "show") == Some(true) { - s.request_ui("view.devCompare", json!({"id": id})); + s.request_ui("view.devCompare", json!({"id": id, "version": version})); } Ok(v) } diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 90e18611..28a242af 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -107,6 +107,20 @@ pub fn specs() -> Vec { } ok() }), + spec!( + "vector.fillRule", + "Set fill rule", + "{ids? | id?, region? (a filled area of a vector; every area when left out), rule: nonZero|evenOdd}", + true, + fill_rule + ), + spec!( + "vector.reverseLoop", + "Reverse loop", + "{ids? | id?, region, loop} (a loop of a filled area; its direction flips, which changes how the area fills)", + true, + reverse_loop + ), spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify), spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path), spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| { @@ -729,3 +743,146 @@ fn resize_to_fit(s: &mut Session, p: &Value) -> Result { } Ok(json!({"ids": fitted})) } + +/// The vector network of `id`, when it is a vector layer. +fn network_mut(doc: &mut Document, id: NodeId) -> Option<&mut VectorNetwork> { + match &mut doc.get_mut(id).ok()?.kind { + NodeKind::Vector { network } => Some(network), + _ => None, + } +} + +/// The id a fill rule is written as. +fn rule_id(rule: WindingRule) -> &'static str { + match rule { + WindingRule::NonZero => "nonZero", + WindingRule::EvenOdd => "evenOdd", + } +} + +/// `region` as an index: absent (or null) means every area. +fn area_index(cmd: &str, p: &Value) -> Result> { + match get(p, "region") { + None | Some(Value::Null) => Ok(None), + Some(v) => { + let k = f64_of(v).ok_or_else(|| bad(cmd, "`region` must be a number"))?; + if !(0.0..1e9).contains(&k) || k.fract() != 0.0 { + return Err(bad(cmd, "`region` out of range")); + } + Ok(Some(k as usize)) + } + } +} + +/// Segments another one- or two-segment loop lists: a short loop takes its direction from the ends +/// its segments are stored with, so re-storing one of those would turn that loop round too. A loop +/// of three or more segments is left out: its ring is walked in list order, which the stored ends +/// don't decide. +fn short_loop_segments(net: &VectorNetwork, skip: (usize, usize)) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + for (r, region) in net.regions.iter().enumerate() { + for (l, lp) in region.loops.iter().enumerate() { + if (r, l) != skip && lp.len() <= 2 { + out.extend(lp.iter().copied()); + } + } + } + out +} + +fn fill_rule(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("vector.fillRule", s, p)?; + let rule = match string(p, "rule").as_deref().map(str::trim) { + Some("evenOdd" | "evenodd" | "EVENODD") => WindingRule::EvenOdd, + Some("nonZero" | "nonzero" | "NONZERO") => WindingRule::NonZero, + _ => return Err(bad("vector.fillRule", "`rule` is nonZero or evenOdd")), + }; + let only = area_index("vector.fillRule", p)?; + let doc = s.doc_mut()?; + let mut count = 0usize; + for id in ids { + let Some(net) = network_mut(doc, id) else { continue }; + match only { + Some(r) => { + if let Some(region) = net.regions.get_mut(r) { + region.winding = rule; + count += 1; + } + } + None => { + for region in &mut net.regions { + region.winding = rule; + count += 1; + } + } + } + } + if count == 0 { + return Err(EngineError::Disabled("vector.fillRule".into(), "no filled area to set (select a vector)".into())); + } + Ok(json!({"regions": count, "rule": rule_id(rule)})) +} + +/// One loop of a vector, worked out but not yet written back: its segments in the new order and, +/// for a loop of one or two segments, the one to store the other way. +struct LoopFlip { + id: NodeId, + turned: Vec, + store: Option, +} + +/// Turn one loop of a filled area round: its segments are walked the other way, so the area it +/// bounds counts the other way in the winding (which turns a hole into a filled part and back). +/// +/// How a loop is walked depends on how many segments it has: +/// - three or more: the order they are listed in, each segment turned to continue from the point +/// reached. Reversing the list is therefore enough, and the stored ends are left alone (a shared +/// segment's ends belong to its neighbour too); +/// - two: the same ring either way round, so one of the two has to be stored the other way instead; +/// - one: only its stored ends say which way it runs, so they are swapped. +/// +/// A short loop can only turn round by re-storing a segment, which is refused when another short +/// loop lists that segment too: it would turn that area round as well. +fn reverse_loop(s: &mut Session, p: &Value) -> Result { + let cmd = "vector.reverseLoop"; + let ids = req_targets(cmd, s, p)?; + let region = req_index(cmd, p, "region")?; + let index = req_index(cmd, p, "loop")?; + let doc = s.doc_mut()?; + // Work every loop out before touching the document: one that can't turn round refuses the call + // and the others stay as they were. + let mut flips: Vec = Vec::new(); + for id in ids { + let Some(NodeKind::Vector { network }) = doc.node(id).map(|n| &n.kind) else { continue }; + let Some(loop_) = network.regions.get(region).and_then(|r| r.loops.get(index)) else { continue }; + if loop_.is_empty() { + continue; + } + let mut store = None; + if loop_.len() <= 2 { + let busy = short_loop_segments(network, (region, index)); + let Some(pick) = loop_.iter().copied().filter(|s| !busy.contains(s)).min() else { + return Err(EngineError::Disabled( + cmd.into(), + "the loop shares its segments with another one- or two-segment loop, so turning it round would turn that one round too".into(), + )); + }; + store = Some(pick); + } + flips.push(LoopFlip { id, turned: loop_.iter().rev().copied().collect(), store }); + } + if flips.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "no such loop (select a vector with filled areas)".into())); + } + for LoopFlip { id, turned, store } in flips { + let Some(net) = network_mut(doc, id) else { continue }; + if let Some(seg) = store.and_then(|i| net.segments.get_mut(i)) { + std::mem::swap(&mut seg.a, &mut seg.b); + std::mem::swap(&mut seg.ta, &mut seg.tb); + } + if let Some(l) = net.regions.get_mut(region).and_then(|r| r.loops.get_mut(index)) { + *l = turned; + } + } + Ok(json!({"reversed": true})) +} diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index af029e5f..e5ac145e 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -804,10 +804,10 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { ok() } -/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts -/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the -/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a -/// typed colour keeps it. An opacity set while bound multiplies on top and stays. +/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the +/// alpha a typed colour had (see `Paint::solid`) out of the opacity, so the variable's alpha applies +/// once; an opacity set on its own, before or while bound, multiplies on top and stays. Unbinding +/// moves the colour's alpha back into the opacity, where a typed colour keeps it. pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) { use super::lists::{BoundList, list_binding}; let Some((list, i, "color")) = list_binding(field) else { return }; @@ -819,7 +819,14 @@ pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], fie if let Some(p) = paints.get_mut(i) && let PaintKind::Solid { color } = &mut p.kind { - p.opacity = if bind { 1.0 } else { p.opacity * color.a }; + if bind { + // What's left is the opacity set on its own (unknown under a colour alpha of 0: 1). + p.opacity = if p.color_alpha > 0.0 { (p.opacity / p.color_alpha).clamp(0.0, 1.0) } else { 1.0 }; + p.color_alpha = 1.0; + } else { + p.opacity *= color.a; + p.color_alpha *= color.a; + } color.a = 1.0; } } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 0592046e..17f71b49 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -200,7 +200,7 @@ fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Re Ok(json!({"on": *slot})) } -fn tool(s: &mut Session, t: ToolId) -> Result { +pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result { // A file you can't edit now (view only, or on its way to a server): moving around only. if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) { return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into())); @@ -465,11 +465,14 @@ pub fn specs() -> Vec { spec!( "tool.set", "Choose tool", - "{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder}", + "{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}", false, |s, p| { let t = req_string("tool.set", p, "tool")?; let t = ToolId::from_id(&t).ok_or_else(|| bad("tool.set", format!("unknown tool `{t}`")))?; + if t.on_boards() { + crate::board::require(s, &format!("tool.{}", t.id()))?; + } tool(s, t) } ), @@ -534,7 +537,14 @@ pub fn specs() -> Vec { spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)), spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)), spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)), - spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)), + // `apply: false` is for the UI colour picker: the eyedropper only samples (into + // `ToolState::picked`) and the picker writes the colour to the paint it edits. + spec!("edit.pickColor", "Pick color", "{apply?}", false, |s, p| { + let r = tool(s, ToolId::Eyedropper)?; + s.tool.pick_only = !boolean(p, "apply").unwrap_or(true); + s.tool.picked = None; + Ok(r) + }), ] } diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/code_connect.rs index 46340636..08b3af02 100644 --- a/crates/engine/src/code_connect.rs +++ b/crates/engine/src/code_connect.rs @@ -14,9 +14,9 @@ use figcraft_doc::*; use serde_json::{Value, json}; use crate::cmd::CommandSpec; -use crate::codegen::CodeLang; +use crate::codegen::{CodeLang, Ctx}; use crate::component_codegen::{Component, Param, ParamKind, describe, instance_values, js_string, main_of, value_expr}; -use crate::components::variant_set; +use crate::components::{slots_of, variant_set}; use crate::params::*; use crate::{EngineError, Result, Session, bad, spec}; @@ -70,14 +70,106 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String { } } -/// Layer `id`'s code with `link`: the import line and the snippet (an instance's values, else the -/// defaults). `None` when `id` isn't a component, set or instance. +/// Layer `id`'s code with `link`: the import lines (its own and those of connected components its +/// instance swaps place) and the snippet (an instance's values, else the defaults). `None` when +/// `id` isn't a component, set or instance. pub fn connected_code(doc: &Document, id: NodeId, lang: CodeLang, link: &CodeLink) -> Option { let c = main_of(doc, id).and_then(|m| describe(doc, m))?; let values = instance_values(doc, id, &c).unwrap_or_else(|| c.params.iter().map(|p| (p.prop.clone(), p.default.clone())).collect()); - let snippet = render(doc, &c, &values, link, lang, ""); - let import = import_line(link, lang); - Some(if import.is_empty() { snippet } else { format!("{import}\n\n{snippet}") }) + let mut imports = Vec::new(); + snippet_imports(doc, &c, &values, link, lang, &mut imports); + Some(with_imports(&imports, render(doc, &c, &values, link, lang, ""))) +} + +/// `code` with `imports` at the top: joined to the import block it starts with, else apart. +pub fn with_imports(imports: &[String], code: String) -> String { + match imports.join("\n") { + i if i.is_empty() => code, + i if code.starts_with("import ") || code.starts_with("use ") => format!("{i}\n{code}"), + i => format!("{i}\n\n{code}"), + } +} + +/// Adds `line` to `out` unless it's empty or already there. +fn add(out: &mut Vec, line: String) { + if !line.is_empty() && !out.contains(&line) { + out.push(line); + } +} + +/// The import lines a connected snippet needs: its own and those of the connected components its +/// instance swaps place (the ones its template shows). +fn snippet_imports(doc: &Document, c: &Component, values: &[(String, Value)], link: &CodeLink, lang: CodeLang, out: &mut Vec) { + add(out, import_line(link, lang)); + for piece in pieces(&link.template) { + if let Piece::Value { name, .. } = piece + && let Some((p, v)) = find(c, values, name) + && p.kind == ParamKind::InstanceSwap + && let Some(l) = id_of(v).and_then(|i| self::link(doc, i, lang)) + { + add(out, import_line(l, lang)); + } + } +} + +/// The import lines of the connected components the generated code of layer `id` calls +/// (instances in its tree, in a component's definition and in the slots an instance fills), once +/// each in tree order. A connected `id` shows its snippet ([`connected_code`]) instead. +pub fn tree_imports(doc: &Document, id: NodeId, lang: CodeLang) -> Vec { + let mut out = Vec::new(); + let Some(n) = doc.node(id) else { return out }; + let c = main_of(doc, id).and_then(|m| describe(doc, m)); + match (&n.kind, &c) { + // Its call (the slots it fills), then its component's definition. + (NodeKind::Instance { .. }, Some(c)) => { + call_imports(doc, id, lang, 0, &mut out); + tree(&Ctx { doc, root: c.comp, comp: Some(c), base: 0 }, c.comp, lang, 0, &mut out); + } + (NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => { + tree(&Ctx { doc, root: c.comp, comp: Some(c), base: 0 }, c.comp, lang, 0, &mut out) + } + _ => tree(&Ctx::new(doc, id), id, lang, 0, &mut out), + } + out +} + +/// Imports under layer `id` as the code walk sees it: its visible children, instances as calls +/// (one an instance swap parameter places is that parameter). shortcut: the depth cap counts from +/// the tree's root, not from each slot's; align them if a deep slot tree shows a stray import. +fn tree(ctx: &Ctx, id: NodeId, lang: CodeLang, depth: usize, out: &mut Vec) { + let Some(n) = ctx.doc.node(id) else { return }; + if ctx.too_deep(depth) { + return; + } + for k in ctx.kids(n) { + let Some(kn) = ctx.doc.node(k) else { continue }; + let call = matches!(kn.kind, NodeKind::Instance { .. }) && main_of(ctx.doc, k).is_some(); + match (call, ctx.param(kn, "mainComponent")) { + (true, Some(_)) => {} + (true, None) => call_imports(ctx.doc, k, lang, depth + 1, out), + (false, _) => tree(ctx, k, lang, depth + 1, out), + } + } +} + +/// The imports instance `id` placed as a call brings: a connected one's snippet's, else those of +/// the content it puts in its slots. +fn call_imports(doc: &Document, id: NodeId, lang: CodeLang, depth: usize, out: &mut Vec) { + let Some(c) = main_of(doc, id).and_then(|m| describe(doc, m)) else { return }; + let Some(values) = instance_values(doc, id, &c) else { return }; + if let Some(l) = link(doc, id, lang) { + return snippet_imports(doc, &c, &values, l, lang, out); + } + let slots = slots_of(doc, id); + for (p, (_, v)) in c.params.iter().zip(&values) { + if p.kind == ParamKind::Slot + && v.as_bool() == Some(true) + && let Some((slot, _)) = slots.iter().find(|(_, name)| *name == p.prop) + { + // The same depth base as the slot's generated content, so its connected children count. + tree(&Ctx { doc, root: *slot, comp: None, base: depth }, *slot, lang, depth + 1, out); + } + } } // ------------------------------------------------------------------ templates @@ -180,14 +272,17 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) -> } } +/// Template `t` as pieces. One that doesn't parse, or that skipped `check` (a hand-edited file) +/// and is far too long, stays as written: parsing rescans after each literal brace. +fn pieces(t: &str) -> Vec> { + let checkable = t.len() <= MAX_TEMPLATE * 4; + checkable.then(|| parse(t).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(t)]) +} + /// `link`'s template with `values` (one per parameter of `c`), every line prefixed by `pad`. pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)], link: &CodeLink, lang: CodeLang, pad: &str) -> String { - // A template that skipped `check` (a hand-edited file) and is far too long stays as written: - // parsing rescans after each literal brace. - let checkable = link.template.len() <= MAX_TEMPLATE * 4; - let pieces = checkable.then(|| parse(&link.template).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(&link.template)]); let mut out = String::new(); - for piece in pieces { + for piece in pieces(&link.template) { match piece { Piece::Text(t) => out.push_str(t), Piece::Value { name, inner } => match find(c, values, name) { diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 0871f886..7ed81b30 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -231,7 +231,7 @@ impl<'a> Ctx<'a> { .collect() } - fn too_deep(&self, depth: usize) -> bool { + pub(crate) fn too_deep(&self, depth: usize) -> bool { depth > self.base + MAX_DEPTH } diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index 1416b15f..a8217be1 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -109,14 +109,14 @@ pub fn specs() -> Vec { /// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's /// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it -/// in a function component). +/// in a function component), with the imports of the connected components it calls at the top. pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { let Some(n) = doc.node(id) else { return String::new() }; if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) { return code; } let comp = main_of(doc, id).and_then(|m| describe(doc, m)); - match (&n.kind, comp) { + let code = match (&n.kind, comp) { (NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)), (NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang), _ => match lang { @@ -135,7 +135,8 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { out } }, - } + }; + crate::code_connect::with_imports(&crate::code_connect::tree_imports(doc, id, lang), code) } fn is_component(doc: &Document, id: NodeId) -> bool { @@ -521,8 +522,7 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option let c = main_of(doc, id).and_then(|m| describe(doc, m))?; let values = instance_values(doc, id, &c)?; let pad = if lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) }; - // A connected component: its real snippet. shortcut: inside a layer tree it brings no import - // line; collect them at the top of the tree's code when people ask for it. + // A connected component: its real snippet (its import is at the top of the code: `code`). if let Some(link) = crate::code_connect::link(doc, id, lang) { return Some(crate::code_connect::render(doc, &c, &values, link, lang, &pad)); } diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 67259d03..91402b58 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -12,9 +12,10 @@ //! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment` //! (of auto layout: its direction and other settings are the main's), `grids`, `exports`, //! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers -//! inside an instance, `component` (a swapped nested instance), `props` (property values of a -//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own -//! export settings, interactions and resizing are its own, like its place. `position` +//! inside an instance, `modes` (the variable modes a layer inside an instance chooses), +//! `component` (a swapped nested instance), `props` (property values of a nested instance) and +//! `slot` (a slot holding the instance's own content). An instance's own export settings, +//! interactions, resizing and variable modes are its own, like its place. `position` //! overrides of layers inside an instance are still read and kept, but new ones are refused //! ([`check_override_edit`]): those layers take their place and constraints from the main. //! @@ -60,6 +61,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[ "size", "resizing", "position", + "modes", "component", "props", SLOT, @@ -114,6 +116,23 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool { enclosing_instance(doc, id).is_some() } +/// Mark the `modes` override on layers inside instances whose variable modes differ from their +/// main layer's, for files from before `modes` was an override group. Copies follow their main +/// layer's modes, so a layer on other modes chose them itself. +pub fn mark_layer_modes(doc: &mut Document) { + let chose: Vec = doc + .nodes() + .filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes)) + .map(|n| n.id) + .filter(|id| inside_instance(doc, *id)) + .collect(); + for id in chose { + if let Some(n) = doc.node_mut(id) { + n.overrides.insert("modes".into()); + } + } +} + /// The slot property frame `id` stands for (its name and preferred components): a frame bound to /// a slot property of its component, or an instance's copy of one (followed through its sources). pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> { @@ -335,6 +354,27 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) { } } +/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis) +/// that is still `was`'s follows `new`; what the instance set itself stays in `own`. +fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) { + if own.sizing_h == was.sizing_h { + own.sizing_h = new.sizing_h; + } + if own.sizing_v == was.sizing_v { + own.sizing_v = new.sizing_v; + } + for (o, w, n) in [ + (&mut own.min_w, was.min_w, new.min_w), + (&mut own.max_w, was.max_w, new.max_w), + (&mut own.min_h, was.min_h, new.min_h), + (&mut own.max_h, was.max_h, new.max_h), + ] { + if *o == w { + *o = n; + } + } +} + /// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them. pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) { @@ -415,6 +455,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { (b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h); } "position" => to.transform = from.transform, + "modes" => to.var_modes = from.var_modes.clone(), "component" => { if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { *b = *a; @@ -779,6 +820,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) { let differs = diff_groups(prev, &comp); root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str())); + follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout); } carry_overrides(&root, &mut fresh, Carry::Root); if let Some(r) = doc.node_mut(inst) { @@ -830,9 +872,10 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT } // Nested instances showing another main than the main's copy (swapped): rebuild from it, // with the old layers below them this rebuild didn't reuse. - let unused: BTreeMap> = old.into_iter().filter(|(i, _)| !used.contains(i) && !doc.contains(*i)).collect(); + let unused: BTreeMap> = + old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect(); for n in nested { - let below: BTreeMap> = unused + let mut below: BTreeMap> = unused .iter() .filter(|(_, x)| { let mut cur = x.parent; @@ -847,10 +890,77 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT }) .map(|(i, x)| (*i, x.clone())) .collect(); + take_placing_overrides(doc, n, &old, &mut below); sync_instance_depth(doc, n, depth + 1, below); } } +/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the +/// copies just made of the outer main's, and `below`) takes the overrides held along the source +/// chain it had before this rebuild (`before`), through layers inside instances in the mains that +/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a +/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main. +/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value. +fn take_placing_overrides( + doc: &mut Document, + n: NodeId, + before: &BTreeMap>, + below: &mut BTreeMap>, +) { + let placing = placing_mains(doc, n); + for id in doc.subtree(n).into_iter().filter(|i| *i != n) { + let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default(); + if let Some(layer) = doc.node_mut(id) { + take(layer, &held); + } + } + for layer in below.values_mut() { + let held = held_along(doc, layer, &placing); + if !held.is_empty() { + take(std::sync::Arc::make_mut(layer), &held); + } + } +} + +/// The mains placing nested instance `n`: those of the layers it mirrors, outward. +fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet { + let mut out = BTreeSet::new(); + let mut cur = doc.node(n).and_then(|x| x.source); + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break }; + out.extend(owning_component(doc, s.id)); + cur = s.source; + } + out +} + +/// The overrides but geometry and slot content held along `layer`'s source chain while it runs +/// through layers inside instances in `placing` mains, each with the nearest layer holding it. +fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet) -> BTreeMap> { + let mut out = BTreeMap::new(); + let mut cur = layer.source; + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break }; + if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) { + break; + } + for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) { + out.entry(g.clone()).or_insert_with(|| s.clone()); + } + cur = s.source; + } + out +} + +/// Give `layer` the `held` groups it has no override of its own for. +fn take(layer: &mut Node, held: &BTreeMap>) { + for (g, from) in held { + if layer.overrides.insert(g.clone()) { + copy_group(from, layer, g); + } + } +} + /// Whether main components `a` and `b` are two variants of one set. fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool { a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s)) @@ -1049,6 +1159,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { if a.interactions != b.interactions { out.push("interactions"); } + if a.var_modes != b.var_modes { + out.push("modes"); + } if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) { if x != y { out.push("component"); @@ -1146,10 +1259,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] } let raw = diff_groups(b, n); let mut groups = raw.clone(); - // An instance's own place, resizing, export settings and interactions are its own, not - // overrides. + // An instance's own place, resizing, export settings, interactions and variable modes are + // its own, not overrides. if top_root { - groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions")); + groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes")); } if groups.iter().any(|g| matches!(*g, "size" | "position")) { groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); @@ -1969,7 +2082,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { let mut groups: Vec = value_groups(m, n) .into_iter() .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) - .filter(|g| placed_source.is_some() || g != "resizing") + .filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) .collect(); let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; if named { @@ -1993,8 +2106,11 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { } if let Some(n) = doc.node_mut(inst) { n.source = root_base; - // A top-level instance's resizing is its own (a nested one's overrides its main layer's). - n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing")); + // A top-level instance's resizing and modes are its own (a nested one's override its main + // layer's). + n.overrides.retain(|g| { + !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) + }); n.overrides.extend(root_groups); } } diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index 7a5c1f90..a95e1268 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -657,6 +657,64 @@ fn switching_variants_keeps_overrides_where_the_variants_agreed() { healthy(&s); } +/// A "Layout" set: Row (horizontal, hugging, a 40 wide "A") and Stacked (vertical, fixed 300 wide, +/// "A" filling it). Returns (set, row, stacked). +fn layout_set(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let mut variants = Vec::new(); + for (k, (name, props, child)) in [ + ("Layout=Row", json!({"layout": "horizontal", "gap": 8, "sizingH": "hug", "sizingV": "hug", "fills": []}), json!({})), + ( + "Layout=Stacked", + json!({"layout": "vertical", "gap": 8, "sizingH": "fixed", "width": 300, "sizingV": "hug", "fills": []}), + json!({"sizingH": "fill"}), + ), + ] + .into_iter() + .enumerate() + { + let c = idv(&ex(s, "node.create", json!({"type": "component", "name": name, "x": k as f64 * 400.0, "props": props}))); + ex(s, "node.create", json!({"type": "rectangle", "parent": c, "name": "A", "width": 40, "height": 20, "props": child})); + variants.push(c); + } + let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()}))); + (set, variants[0], variants[1]) +} + +#[test] +fn switching_variants_takes_the_new_variants_resizing_unless_the_instance_set_its_own() { + let mut s = Session::new(); + let (set, row, stacked) = layout_set(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200}))); + assert_eq!(component_of(&s, i), row); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Layout": "Stacked"}})); + assert_eq!(component_of(&s, i), stacked); + let direct = idv(&ex(&mut s, "instance.create", json!({"component": stacked, "x": 0, "y": 400}))); + let (n, d) = (node(&s, i), node(&s, direct)); + assert_eq!(n.child_layout.sizing_h, Sizing::Fixed, "Row's Hug was never the instance's own: it takes Stacked's Fixed"); + assert_eq!((n.width, n.child_layout.sizing_h), (d.width, d.child_layout.sizing_h), "the same as an instance made from Stacked"); + let a = layer(&s, i, "A").unwrap(); + assert_eq!((node(&s, a).child_layout.sizing_h, node(&s, a).width), (Sizing::Fill, 300.0), "A fills the 300 wide instance"); + // Filling an auto layout parent afterwards leaves A filling, with nothing turned into an override. + let col = idv(&ex( + &mut s, + "node.create", + json!({"type": "frame", "name": "Column", "x": 600, "y": 0, "width": 500, "props": {"layout": "vertical", "sizingH": "fixed", "sizingV": "hug", "fills": []}}), + )); + ex(&mut s, "node.reparent", json!({"ids": [i], "parent": col})); + ex(&mut s, "node.set", json!({"ids": [i], "props": {"sizingH": "fill"}})); + let a = layer(&s, i, "A").unwrap(); + assert_eq!(node(&s, a).child_layout.sizing_h, Sizing::Fill, "A still fills"); + assert!(!node(&s, a).overrides.contains("resizing"), "A's resizing is still the main's"); + assert_eq!(node(&s, a).width, node(&s, i).width); + // Resizing the instance set itself stays across a switch. + let own = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600}))); + ex(&mut s, "node.set", json!({"ids": [own], "props": {"minWidth": 120}})); + ex(&mut s, "instance.setProperties", json!({"ids": [own], "properties": {"Layout": "Stacked"}})); + assert_eq!(node(&s, own).child_layout.min_w, Some(120.0), "its own min width stays"); + assert_eq!(node(&s, own).child_layout.sizing_h, Sizing::Fixed, "its sizing, still Row's, follows Stacked"); + healthy(&s); +} + #[test] fn variant_combinations_order_default_and_conflicts() { let mut s = Session::new(); diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/components_rebase_source_tests.rs index b272b82f..94eae71f 100644 --- a/crates/engine/src/components_rebase_source_tests.rs +++ b/crates/engine/src/components_rebase_source_tests.rs @@ -201,6 +201,20 @@ fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() { assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0); } +#[test] +fn rebase_marks_a_nested_roots_own_mode_but_not_a_top_level_instances() { + let mut s = scene(); + let modes = BTreeMap::from([("Tone".to_string(), "B".to_string())]); + s.doc.node_mut(s.nested).unwrap().var_modes = modes.clone(); + both(&mut s); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("modes"), "its slot in Outer sets no mode"); + let page = s.doc.first_page().unwrap(); + let detached = mirror(&mut s.doc, s.nested, page); + rebase_instance(&mut s.doc, detached); + let n = s.doc.node(detached).unwrap(); + assert_eq!((&n.var_modes, n.overrides.contains("modes")), (&modes, false), "a top-level instance's modes are its own"); +} + #[test] fn rebase_does_not_preserve_existing_foreign_or_stale_sources() { let mut s = scene(); diff --git a/crates/engine/src/devcompare.rs b/crates/engine/src/devcompare.rs index 58cf9aa8..ef40a2fb 100644 --- a/crates/engine/src/devcompare.rs +++ b/crates/engine/src/devcompare.rs @@ -1,6 +1,7 @@ //! Dev Mode "Compare changes": what changed in a ready-for-dev frame or section since it was -//! marked. The document keeps a snapshot of the layer from just before its first change -//! ([`figcraft_doc::handoff`]); this compares it with the layer as it is now, layer by layer +//! marked, or in any frame or section since a saved version. The document keeps a snapshot of a +//! ready-for-dev layer from just before its first change ([`figcraft_doc::handoff`]); this compares +//! it (or the layer in the version) with the layer as it is now, layer by layer //! (matched by id): layers added and removed, and for the others their properties formatted the //! way Inspect shows them (`width 120 → 136`, `fill #FF0000 → #E03030`). @@ -93,20 +94,25 @@ pub struct Comparison { pub truncated: bool, } -/// The changes to layer `id` since it was marked ready for dev (empty when it hasn't changed). -pub fn compare(doc: &Document, id: NodeId) -> Comparison { - let n = doc.node(id); - let mut out = Comparison { +/// Nothing compared yet for layer `id` of `doc` (`snapshot` false: the before isn't known). +pub fn empty(doc: &Document, id: NodeId) -> Comparison { + Comparison { id, - name: n.map(|n| n.name.clone()).unwrap_or_default(), - changed: n.is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed), + name: doc.node(id).map(|n| n.name.clone()).unwrap_or_default(), + changed: false, snapshot: false, changes: Vec::new(), added: 0, removed: 0, modified: 0, truncated: false, - }; + } +} + +/// The changes to layer `id` since it was marked ready for dev (empty when it hasn't changed). +pub fn compare(doc: &Document, id: NodeId) -> Comparison { + let mut out = empty(doc, id); + out.changed = doc.node(id).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed); if !out.changed { return out; } @@ -117,6 +123,21 @@ pub fn compare(doc: &Document, id: NodeId) -> Comparison { out } +/// The changes to layer `id` of `now` since `before` (a saved version of the same file: layers +/// match by id). A layer the version doesn't have was added as a whole. +pub fn compare_with(before: &Document, now: &Document, id: NodeId) -> Comparison { + let mut out = empty(now, id); + out.snapshot = true; + let mut w = Walk { before, now, now_ids: bounded_set(now, id), before_ids: bounded_set(before, id), out: &mut out }; + if before.contains(id) { + w.layer(id, 0); + } else { + w.whole(now, id, 0, ChangeKind::Added); + } + out.changed = !out.changes.is_empty(); + out +} + /// The before and after documents of layer `id` (the snapshot's copy and the document), for /// rendering the two side by side. pub fn before_doc(doc: &Document, id: NodeId) -> Option { @@ -127,17 +148,7 @@ pub fn before_doc(doc: &Document, id: NodeId) -> Option { /// Every layer that changed between two versions of a whole document, page by page (pages added /// or removed as a whole), for reviewing a branch. pub fn compare_docs(before: &Document, now: &Document) -> Comparison { - let mut out = Comparison { - id: NodeId(0), - name: now.name.clone(), - changed: false, - snapshot: true, - changes: Vec::new(), - added: 0, - removed: 0, - modified: 0, - truncated: false, - }; + let mut out = Comparison { name: now.name.clone(), snapshot: true, ..empty(now, NodeId(0)) }; let mut w = Walk { before, now, now_ids: now.ids().collect(), before_ids: before.ids().collect(), out: &mut out }; for p in &now.pages { if before.contains(*p) { diff --git a/crates/engine/src/devfocus_tests.rs b/crates/engine/src/devfocus_tests.rs index 1607ce56..0801746d 100644 --- a/crates/engine/src/devfocus_tests.rs +++ b/crates/engine/src/devfocus_tests.rs @@ -99,8 +99,9 @@ fn nested_frames_stay_out_of_the_ready_list_and_its_navigation() { let mut s = Session::new(); let screen = frame(&mut s, 0.0, "Screen"); let card = idv(&ex(&mut s, "frame.create", json!({"parent": screen, "width": 100, "height": 50, "name": "Card"}))); - // A status a nested frame already stores (an older file, or set through node.set) is ignored. - ex(&mut s, "node.set", json!({"id": card, "props": {"devStatus": "readyForDev"}})); + // node.set refuses a status on a nested frame, and one an older file already stores is ignored. + assert!(s.execute("node.set", &json!({"id": card, "props": {"devStatus": "readyForDev"}})).is_err()); + s.doc_mut().unwrap().node_mut(card).unwrap().dev_status = DevStatus::ReadyForDev; assert_eq!(ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))["items"], json!([])); assert!(s.execute("dev.focus", &json!({"direction": "next"})).is_err(), "nothing is ready"); ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}})); diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index fe400989..16f4bbdc 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -214,6 +214,21 @@ pub struct DevItem { pub name: String, pub page: NodeId, pub status: DevStatus, + /// Ready for dev and changed since it was marked. + pub changed: bool, +} + +/// Whether Dev Mode runs command `id` from the menus and shortcuts: commands that don't edit the +/// document, and Dev Mode's own edits (annotations, statuses, dev resources, code connections, +/// comments). Choosing a tool other than moving around and commenting is refused too, and so is +/// Revert to saved, which isn't an undo step but throws the design's unsaved edits away. +pub fn dev_mode_allows(id: &str) -> bool { + const OWN: [&str; 5] = ["annotation.", "dev.", "devResource.", "codeConnect.", "comment."]; + match id.strip_prefix("tool.") { + Some(t) => matches!(t, "move" | "hand" | "comment"), + None if id == "file.revert" => false, + None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history), + } } /// Most items listed. @@ -244,7 +259,8 @@ pub fn dev_items(doc: &Document, status: Option, page: Option } let Some(n) = doc.node(id) else { continue }; if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) { - out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status }); + let changed = n.dev_status == DevStatus::ReadyForDev && n.dev_changed; + out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status, changed }); } stack.extend(n.children.iter().rev().copied()); } @@ -257,18 +273,21 @@ pub fn specs() -> Vec { spec!( "inspect.readyForDev", "Ready for dev", - "{status?: readyForDev|completed|any (default readyForDev), page? (page id; default every page)} → {items: [{id, name, page, status}]} frames and sections by handoff status (set it with `node.set {devStatus}`)", + "{status?: readyForDev|changed|completed|any (default readyForDev; changed: ready for dev and changed since marked), page? (page id; default every page)} → {items: [{id, name, page, status, changed}]} frames and sections by handoff status (set it with `node.set {devStatus}`)", false, |s, p| { let cmd = "inspect.readyForDev"; - let status = match string(p, "status").as_deref() { + let wanted = string(p, "status"); + let changed_only = wanted.as_deref().is_some_and(|v| v.eq_ignore_ascii_case("changed")); + let status = match wanted.as_deref() { None => Some(DevStatus::ReadyForDev), + _ if changed_only => Some(DevStatus::ReadyForDev), Some(v) if v.eq_ignore_ascii_case("any") || v.eq_ignore_ascii_case("all") => None, Some(v) => Some( DevStatus::ALL .into_iter() .find(|d| !d.is_none() && d.id().eq_ignore_ascii_case(v)) - .ok_or_else(|| crate::bad(cmd, "status: readyForDev|completed|any"))?, + .ok_or_else(|| crate::bad(cmd, "status: readyForDev|changed|completed|any"))?, ), }; let doc = s.doc()?; @@ -276,7 +295,9 @@ pub fn specs() -> Vec { Some(v) => Some(id_of(v).filter(|pg| doc.pages.contains(pg)).ok_or_else(|| crate::bad(cmd, "page: a page id"))?), None => None, }; - Ok(json!({"items": serde_json::to_value(dev_items(doc, status, page)).unwrap_or(Value::Null)})) + let mut items = dev_items(doc, status, page); + items.retain(|i| i.changed || !changed_only); + Ok(json!({"items": serde_json::to_value(items).unwrap_or(Value::Null)})) } ), spec!( diff --git a/crates/engine/src/devinspect_tests.rs b/crates/engine/src/devinspect_tests.rs index be25e02c..5ee590cc 100644 --- a/crates/engine/src/devinspect_tests.rs +++ b/crates/engine/src/devinspect_tests.rs @@ -79,7 +79,7 @@ fn redlines_measure_on_hover_without_alt() { s.tool.overlay.guides.len() }; assert_eq!(hover(&mut s), 0, "plain hover doesn't measure"); - s.tool.redlines = true; + s.tool.dev_mode = true; assert!(hover(&mut s) > 0, "Inspect measures on hover"); } diff --git a/crates/engine/src/devmode_behaviour_tests.rs b/crates/engine/src/devmode_behaviour_tests.rs new file mode 100644 index 00000000..3d85fdb1 --- /dev/null +++ b/crates/engine/src/devmode_behaviour_tests.rs @@ -0,0 +1,224 @@ +//! Dev Mode behaviour suite, written from the public documentation of handoff tools (in our own +//! words): the canvas inspects and never edits, what the menus and shortcuts may run there, the +//! list of what's ready for development, statuses (ready, changed, completed) and the focus view. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use crate::devinspect::dev_mode_allows; +use crate::tools::{PointerEvent, PointerKind, ToolId}; +use figcraft_doc::{DevStatus, NodeId}; +use serde_json::{Value, json}; + +fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::at(kind, x, y, 1.0) +} + +fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) { + s.pointer(&ev(PointerKind::Down, from.0, from.1)).unwrap(); + for k in 1..=4 { + let t = f64::from(k) / 4.0; + s.pointer(&ev(PointerKind::Move, from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t)).unwrap(); + } + s.pointer(&ev(PointerKind::Up, to.0, to.1)).unwrap(); +} + +fn frame(s: &mut Session, x: f64, name: &str) -> NodeId { + idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 200, "height": 200, "name": name}))) +} + +fn rect(s: &mut Session, parent: NodeId, x: f64) -> NodeId { + idv(&ex(s, "shape.rectangle", json!({"x": x, "y": 20, "width": 40, "height": 40, "parent": parent}))) +} + +fn names(v: &Value) -> Vec { + v["items"].as_array().map(|a| a.iter().filter_map(|i| i["name"].as_str().map(String::from)).collect()).unwrap_or_default() +} + +fn status(s: &mut Session, ids: &[NodeId], st: &str) { + ex(s, "node.set", json!({"ids": ids, "props": {"devStatus": st}})); +} + +#[test] +fn the_dev_mode_canvas_selects_and_pans_but_never_edits() { + let mut s = Session::new(); + let f = frame(&mut s, 0.0, "Screen"); + let r = rect(&mut s, f, 20.0); + let t = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 100, "y": 120, "parent": f}))); + s.tool.dev_mode = true; + let before = s.doc().unwrap().clone(); + let steps = s.undo_labels(100).len(); + // A press selects; dragging it moves nothing. + drag(&mut s, (40.0, 40.0), (140.0, 90.0)); + assert_eq!(s.selection(), vec![r]); + // Nor do its resize handles. + drag(&mut s, (60.0, 60.0), (120.0, 120.0)); + // Drawing tools only select there. + for tool in [ToolId::Rectangle, ToolId::Frame, ToolId::Text, ToolId::Pen, ToolId::Pencil, ToolId::Scale] { + s.set_tool(tool); + drag(&mut s, (300.0, 300.0), (360.0, 360.0)); + } + s.set_tool(ToolId::Move); + // A double-click on text doesn't start typing; on a frame's layer it selects into it. + ex(&mut s, "select.set", json!({"ids": [t]})); + s.pointer(&ev(PointerKind::DoubleClick, 110.0, 128.0)).unwrap(); + assert!(s.ds().unwrap().text_edit.is_none(), "no text editing"); + ex(&mut s, "select.set", json!({"ids": [r]})); + s.pointer(&ev(PointerKind::DoubleClick, 40.0, 40.0)).unwrap(); + assert!(s.ds().unwrap().vector_edit.is_none(), "no vector editing"); + assert!(*s.doc().unwrap() == before, "the design is untouched"); + assert_eq!(s.undo_labels(100).len(), steps, "nothing to undo"); + // Keys that edit with a tool (a selected guide's Delete) go to the commands instead. + assert!(!s.tool_key("Delete", false, false, false).unwrap()); + // The hand still pans; comments still start. + s.set_tool(ToolId::Hand); + let cx = s.ds().unwrap().view.cx; + s.pointer(&ev(PointerKind::Down, 500.0, 500.0)).unwrap(); + s.pointer(&PointerEvent { sx: 550.0, ..ev(PointerKind::Move, 500.0, 500.0) }).unwrap(); + s.pointer(&ev(PointerKind::Up, 500.0, 500.0)).unwrap(); + assert_ne!(s.ds().unwrap().view.cx, cx, "panned"); + s.set_tool(ToolId::Comment); + s.pointer(&ev(PointerKind::Down, 500.0, 500.0)).unwrap(); + assert!(s.ui_requests.iter().any(|u| u.kind == "comment.new")); + // Back in the design: the same drag moves. + s.tool.dev_mode = false; + s.set_tool(ToolId::Move); + drag(&mut s, (40.0, 40.0), (140.0, 90.0)); + assert!(s.doc().unwrap().node(r).is_some_and(|n| n.transform.as_coeffs()[4] > 100.0), "moved"); +} + +#[test] +fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts() { + for id in [ + "annotation.add", + "annotation.edit", + "dev.resolveChange", + "dev.focus", + "devResource.add", + "codeConnect.set", + "comment.add", + "edit.undo", + "edit.redo", + "edit.copy", + "edit.copyAsCss", + "select.all", + "view.zoomIn", + "view.devMode", + "file.export", + "file.save", + "tool.move", + "tool.hand", + "tool.comment", + "inspect.code", + ] { + assert!(dev_mode_allows(id), "{id} runs in Dev Mode"); + } + for id in [ + "edit.delete", + "edit.paste", + "edit.cut", + "edit.duplicate", + "transform.nudge", + "object.group", + "tool.rectangle", + "tool.text", + "tool.place", + "file.import", + "file.revert", + ] { + assert!(!dev_mode_allows(id), "{id} edits the design"); + } +} + +#[test] +fn the_ready_list_shows_frames_and_sections_with_a_status_across_pages() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0, "Login"); + let b = frame(&mut s, 300.0, "Signup"); + let nested = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 50, "height": 50, "parent": a, "name": "Card"}))); + status(&mut s, &[a], "readyForDev"); + status(&mut s, &[b], "completed"); + // A layer inside a frame takes no status of its own (its frame's covers it). + assert!(s.execute("node.set", &json!({"id": nested, "props": {"devStatus": "readyForDev"}})).is_err()); + assert!(s.execute("node.set", &json!({"ids": [a, nested], "props": {"devStatus": "completed"}})).is_err(), "all or nothing"); + assert_eq!(s.doc().unwrap().node(a).map(|n| n.dev_status), Some(DevStatus::ReadyForDev)); + // Clearing one is always fine. + ex(&mut s, "node.set", json!({"id": nested, "props": {"devStatus": null}})); + let pg2 = idv(&ex(&mut s, "page.add", json!({"name": "Settings"}))); + ex(&mut s, "page.select", json!({"id": pg2})); + let c = frame(&mut s, 0.0, "Profile"); + status(&mut s, &[c], "readyForDev"); + assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({}))), vec!["Login", "Profile"]); + assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]); + assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))), vec!["Login", "Signup", "Profile"]); + // Statuses are undoable. + s.undo().unwrap(); + assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({}))), vec!["Login"]); +} + +#[test] +fn ready_frames_edited_after_marking_show_as_changed_until_resolved_or_completed() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0, "Login"); + let b = frame(&mut s, 300.0, "Signup"); + let r = rect(&mut s, a, 20.0); + let outside = idv(&ex(&mut s, "shape.rectangle", json!({"x": 900, "y": 0, "width": 10, "height": 10}))); + status(&mut s, &[a, b], "readyForDev"); + let changed = |s: &mut Session| names(&ex(s, "inspect.readyForDev", json!({"status": "changed"}))); + assert!(changed(&mut s).is_empty(), "marking isn't a change"); + // Edits elsewhere don't count; an edit inside does. + ex(&mut s, "node.set", json!({"id": outside, "props": {"width": 30}})); + assert!(changed(&mut s).is_empty()); + ex(&mut s, "node.set", json!({"id": r, "props": {"width": 60}})); + assert_eq!(changed(&mut s), vec!["Login"]); + let items = ex(&mut s, "inspect.readyForDev", json!({})); + assert_eq!(items["items"][0]["changed"], true, "the list says which changed: {items}"); + assert_eq!(items["items"][1]["changed"], false); + // Still ready for dev (the status itself stays). + assert_eq!(names(&items), vec!["Login", "Signup"]); + // Resolving goes back to plain ready; so does marking it again, or completing it. + ex(&mut s, "dev.resolveChange", json!({"ids": [a]})); + assert!(changed(&mut s).is_empty()); + ex(&mut s, "node.set", json!({"id": r, "props": {"width": 70}})); + status(&mut s, &[a], "completed"); + assert!(changed(&mut s).is_empty()); + let all = ex(&mut s, "inspect.readyForDev", json!({"status": "any"})); + assert_eq!(all["items"][0]["changed"], false, "a completed frame isn't changed: {all}"); + // Completed frames don't turn changed when edited. + ex(&mut s, "node.set", json!({"id": r, "props": {"width": 80}})); + assert!(!s.doc().unwrap().node(a).unwrap().dev_changed); +} + +#[test] +fn the_focus_view_steps_through_what_is_ready_and_skips_completed_frames() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0, "Login"); + let b = frame(&mut s, 300.0, "Signup"); + let c = frame(&mut s, 600.0, "Profile"); + let r = rect(&mut s, c, 620.0); + status(&mut s, &[a, c], "readyForDev"); + status(&mut s, &[b], "completed"); + ex(&mut s, "node.set", json!({"id": r, "props": {"width": 60}})); + let step = |s: &mut Session, d: &str| ex(s, "dev.focus", json!({"direction": d})); + let v = ex(&mut s, "dev.focus", json!({"id": a})); + assert_eq!((v["index"].clone(), v["count"].clone()), (json!(0), json!(2))); + assert_eq!(step(&mut s, "next")["id"], json!(c), "completed ones are skipped, changed ones aren't"); + assert_eq!(step(&mut s, "previous")["id"], json!(a)); + // Any top-level frame opens in it, even one that's completed. + assert_eq!(ex(&mut s, "dev.focus", json!({"id": b}))["index"], json!(null)); +} + +#[test] +fn a_drag_under_way_when_dev_mode_starts_moves_nothing() { + let mut s = Session::new(); + let f = frame(&mut s, 0.0, "Screen"); + let r = rect(&mut s, f, 20.0); + ex(&mut s, "select.set", json!({"ids": [r]})); + let before = s.doc().unwrap().world_bounds(r); + s.pointer(&ev(PointerKind::Down, 40.0, 40.0)).unwrap(); + s.pointer(&ev(PointerKind::Move, 90.0, 40.0)).unwrap(); + s.drop_gesture().unwrap(); + s.tool.dev_mode = true; + s.pointer(&ev(PointerKind::Move, 120.0, 40.0)).unwrap(); + s.pointer(&ev(PointerKind::Up, 120.0, 40.0)).unwrap(); + assert_eq!(s.doc().unwrap().world_bounds(r), before, "the drag was dropped, not committed"); +} diff --git a/crates/engine/src/devmode_tail_tests.rs b/crates/engine/src/devmode_tail_tests.rs new file mode 100644 index 00000000..b8f1b019 --- /dev/null +++ b/crates/engine/src/devmode_tail_tests.rs @@ -0,0 +1,163 @@ +//! Dev Mode, the tail: import lines for the connected components a layer tree calls. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use figcraft_doc::NodeId; +use serde_json::json; + +fn code(s: &mut Session, id: NodeId, lang: &str) -> String { + ex(s, "inspect.code", json!({"id": id, "language": lang}))["code"].as_str().unwrap_or_default().to_string() +} + +/// A component named `name` (an empty frame) at `x`. +fn component(s: &mut Session, name: &str, x: f64) -> NodeId { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 1000, "width": 40, "height": 20, "name": name}))); + idv(&ex(s, "component.create", json!({"ids": [f]}))) +} + +fn instance(s: &mut Session, c: NodeId, parent: NodeId) -> NodeId { + idv(&ex(s, "instance.create", json!({"component": c, "x": 0, "y": 0, "parent": parent}))) +} + +fn frame(s: &mut Session, name: &str, parent: Option) -> NodeId { + idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300, "name": name, "parent": parent}))) +} + +fn count(code: &str, line: &str) -> usize { + code.lines().filter(|l| *l == line).count() +} + +#[test] +fn layer_trees_import_the_connected_components_they_call_once_at_the_top() { + let mut s = Session::new(); + let badge = component(&mut s, "Badge", 0.0); + let button = component(&mut s, "Button", 100.0); + let toast = component(&mut s, "Toast", 200.0); + let divider = component(&mut s, "Divider", 300.0); + for (lang, path) in [("react", "@acme/ui"), ("swiftui", "AcmeUI"), ("leptos", "crate::ui"), ("compose", "com.acme.ui.Badge")] { + ex(&mut s, "codeConnect.set", json!({"id": badge, "language": lang, "importPath": path})); + } + ex(&mut s, "codeConnect.set", json!({"id": button, "importPath": "@acme/buttons"})); + ex(&mut s, "codeConnect.set", json!({"id": toast, "importPath": "@acme/toast"})); + let screen = frame(&mut s, "Screen", None); + let row = frame(&mut s, "Row", Some(screen)); + for (c, parent) in [(badge, screen), (button, screen), (badge, screen), (divider, screen), (badge, row)] { + instance(&mut s, c, parent); + } + let hidden = instance(&mut s, toast, row); + ex(&mut s, "node.set", json!({"id": hidden, "props": {"visible": false}})); + + let react = code(&mut s, screen, "react"); + assert!(react.starts_with("import { Badge } from \"@acme/ui\";\nimport { Button } from \"@acme/buttons\";\nimport \"./"), "{react}"); + assert_eq!(count(&react, "import { Badge } from \"@acme/ui\";"), 1, "once each: {react}"); + assert!(!react.contains("@acme/toast"), "hidden layers aren't in the code: {react}"); + let swift = code(&mut s, screen, "swiftui"); + assert!(swift.starts_with("import AcmeUI\n\n") && count(&swift, "import AcmeUI") == 1, "per language: {swift}"); + assert!(!swift.contains("acme/buttons"), "{swift}"); + let leptos = code(&mut s, screen, "leptos"); + assert!(leptos.starts_with("use crate::ui::Badge;\nuse leptos::prelude::*;\n"), "joined to the code's own imports: {leptos}"); + assert!(code(&mut s, screen, "compose").starts_with("import com.acme.ui.Badge\n\n")); + assert!(!code(&mut s, screen, "css").contains("import"), "CSS has no imports"); + // The Inspect panel shows inspect.code's text (the same function). + let doc = s.doc().unwrap(); + assert_eq!(crate::codegen::code_for(doc, screen, crate::codegen::CodeLang::React), react); + + // A connected layer itself: its import and snippet, unchanged. + assert_eq!(code(&mut s, badge, "react"), "import { Badge } from \"@acme/ui\";\n\n\n"); + + // A component's definition, and an instance of it (its call, then the definition). + let card_frame = frame(&mut s, "Card", None); + instance(&mut s, badge, card_frame); + let card = idv(&ex(&mut s, "component.create", json!({"ids": [card_frame]}))); + assert!( + code(&mut s, card, "react").starts_with("import { Badge } from \"@acme/ui\";\nimport \"./card.css\";"), + "{}", + code(&mut s, card, "react") + ); + let page = frame(&mut s, "Page", None); + let card_i = instance(&mut s, card, page); + assert!(code(&mut s, card_i, "react").starts_with("import { Badge } from \"@acme/ui\";\n\n"), "{}", code(&mut s, card_i, "react")); + assert!(code(&mut s, page, "react").starts_with("import \"./"), "an unconnected call's insides aren't in the code"); +} + +#[test] +fn slots_and_instance_swaps_bring_their_connected_imports() { + let mut s = Session::new(); + let badge = component(&mut s, "Badge", 0.0); + ex(&mut s, "codeConnect.set", json!({"id": badge, "importPath": "@acme/ui"})); + // A panel whose slot an instance fills with a badge. + let f = frame(&mut s, "Panel", None); + let content = frame(&mut s, "Content", Some(f)); + let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + ex(&mut s, "component.makeSlot", json!({"id": content})); + let screen = frame(&mut s, "Screen", None); + let p = instance(&mut s, panel, screen); + assert!(!code(&mut s, screen, "react").contains("@acme/ui"), "an empty slot"); + ex(&mut s, "instance.slotInsert", json!({"id": p, "slot": "Content", "component": badge, "replace": true})); + assert!(code(&mut s, screen, "react").starts_with("import { Badge } from \"@acme/ui\";\n"), "{}", code(&mut s, screen, "react")); + assert!(code(&mut s, p, "react").starts_with("import { Badge } from \"@acme/ui\";\n\n"})); + let c = instance(&mut s, chip, screen); + ex(&mut s, "instance.setProperties", json!({"ids": [c], "properties": {"Icon": badge}})); + assert_eq!(code(&mut s, c, "react"), "import { Chip } from \"@acme/chip\";\nimport { Badge } from \"@acme/ui\";\n\n} />\n"); + let tree = code(&mut s, screen, "react"); + assert!(tree.starts_with("import { Badge } from \"@acme/ui\";\nimport { Chip } from \"@acme/chip\";\nimport \"./"), "{tree}"); + // An unconnected swap target brings nothing; the default (Heart) isn't connected. + ex(&mut s, "instance.setProperties", json!({"ids": [c], "properties": {"Icon": heart}})); + assert_eq!(code(&mut s, c, "react"), "import { Chip } from \"@acme/chip\";\n\n} />\n"); +} + +#[test] +fn compare_changes_takes_any_saved_version_as_the_before() { + let mut s = Session::new(); + let screen = frame(&mut s, "Screen", None); + let button = + idv(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 120, "height": 40, "parent": screen, "props": {"name": "Button"}}))); + let old = idv(&ex(&mut s, "shape.ellipse", json!({"x": 200, "y": 20, "width": 20, "height": 20, "parent": screen, "props": {"name": "Dot"}}))); + let v1 = ex(&mut s, "file.saveVersion", json!({"name": "Kickoff"}))["id"].as_str().unwrap().to_string(); + ex(&mut s, "node.set", json!({"id": button, "props": {"width": 136}})); + ex(&mut s, "edit.delete", json!({"ids": [old]})); + let added = idv(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 20, "y": 100, "parent": screen, "props": {"name": "Note"}}))); + let v2 = ex(&mut s, "file.saveVersion", json!({"name": "Review"}))["id"].as_str().unwrap().to_string(); + ex(&mut s, "node.set", json!({"id": button, "props": {"width": 150}})); + + // Any frame, ready for dev or not. + let c = ex(&mut s, "dev.compare", json!({"id": screen, "version": v1})); + assert_eq!(c["version"]["name"], "Kickoff"); + assert_eq!( + (c["changed"].as_bool(), c["added"].as_u64(), c["removed"].as_u64(), c["modified"].as_u64()), + (Some(true), Some(1), Some(1), Some(1)), + "{c}" + ); + let change = + |c: &serde_json::Value, id: NodeId| c["changes"].as_array().unwrap().iter().find(|l| l["id"] == json!(id)).cloned().unwrap_or_default(); + assert_eq!(change(&c, button)["properties"][0]["text"], "width 120 → 150"); + assert_eq!(change(&c, old)["change"], "removed"); + assert_eq!(change(&c, added)["change"], "added"); + // A later version: only what changed since then. + let c = ex(&mut s, "dev.compare", json!({"id": screen, "version": v2})); + assert_eq!(c["changes"].as_array().map(Vec::len), Some(1), "{c}"); + assert_eq!(change(&c, button)["properties"][0]["text"], "width 136 → 150"); + // A frame the version doesn't have was added as a whole. + let fresh = frame(&mut s, "Settings", None); + let c = ex(&mut s, "dev.compare", json!({"id": fresh, "version": v1})); + assert_eq!(c["changes"][0]["change"], "added"); + // The snapshot from when it was marked stays the default, for ready-for-dev layers only. + assert!(s.execute("dev.compare", &json!({"id": screen})).is_err(), "not ready for dev"); + ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}})); + let c = ex(&mut s, "dev.compare", json!({"id": screen})); + assert_eq!((c["changed"].as_bool(), c["version"].is_null()), (Some(false), true), "{c}"); + for bad in [json!({"id": screen, "version": "nope"}), json!({"id": screen, "version": 5}), json!({"id": button, "version": v1})] { + assert!(s.execute("dev.compare", &bad).is_err(), "{bad}"); + } + // The app opens on the version asked for. + ex(&mut s, "dev.compare", json!({"id": screen, "version": v2, "show": true})); + assert!(s.ui_requests.iter().any(|u| u.kind == "view.devCompare" && u.data["version"] == json!(v2))); +} diff --git a/crates/engine/src/fill_none_tests.rs b/crates/engine/src/fill_none_tests.rs new file mode 100644 index 00000000..74fe6231 --- /dev/null +++ b/crates/engine/src/fill_none_tests.rs @@ -0,0 +1,88 @@ +//! `props.fill` and `props.stroke` accept `"none"` (either case) and `null` as no paint, the +//! same as an empty `fills`/`strokes` list (#1036): an outline-only shape in one call. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; + +fn id(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn create_rect(s: &mut Session, fill: Value) -> NodeId { + id(ex( + s, + "node.create", + json!({ + "type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, + "props": {"fill": fill, "stroke": "#000000", "strokeWeight": 1} + }), + )) +} + +#[test] +fn fill_none_or_null_makes_an_outline_only_shape_in_one_call() { + for none in [json!("none"), json!("None"), json!("NONE"), json!(null)] { + let mut s = Session::new(); + let r = create_rect(&mut s, none.clone()); + let n = s.doc().unwrap().node(r).unwrap().clone(); + assert!(n.fills.is_empty(), "{none:?}: fills not empty"); + assert_eq!(n.strokes.len(), 1, "{none:?}: stroke kept"); + } +} + +#[test] +fn fill_none_clears_an_existing_fill_as_one_undo_step() { + let mut s = Session::new(); + let r = create_rect(&mut s, json!("#FF0000")); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1); + ex(&mut s, "node.set", json!({"id": r, "props": {"fill": "none"}})); + assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty()); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1); +} + +#[test] +fn stroke_none_or_null_clears_the_strokes() { + for none in [json!("none"), json!(null)] { + let mut s = Session::new(); + let r = create_rect(&mut s, json!("#FF0000")); + ex(&mut s, "node.set", json!({"id": r, "props": {"stroke": none}})); + assert!(s.doc().unwrap().node(r).unwrap().strokes.is_empty(), "{none:?}"); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1, "{none:?}"); + } +} + +#[test] +fn text_with_fill_none_gets_no_automatic_colour() { + let mut s = Session::new(); + let t = id(ex( + &mut s, + "node.create", + json!({ + "type": "text", "x": 0, "y": 0, "text": "Hi", "props": {"fill": "none"} + }), + )); + assert!(s.doc().unwrap().node(t).unwrap().fills.is_empty()); +} + +#[test] +fn an_unknown_string_still_names_the_key() { + let mut s = Session::new(); + let e = s + .execute( + "node.create", + &json!({ + "type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, + "props": {"fill": "nope"} + }), + ) + .unwrap_err(); + let msg = e.to_string(); + assert!(msg.contains("`fill`") && msg.contains("none"), "{msg}"); +} diff --git a/crates/engine/src/fx_behaviour_tests.rs b/crates/engine/src/fx_behaviour_tests.rs index eb8da9b2..d9d93592 100644 --- a/crates/engine/src/fx_behaviour_tests.rs +++ b/crates/engine/src/fx_behaviour_tests.rs @@ -53,6 +53,29 @@ fn the_eyedropper_picks_the_colour_seen_and_fills_the_selection() { assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into())); } +#[test] +fn the_eyedropper_of_a_colour_picker_samples_without_filling() { + // Opened from a colour picker (`apply: false`), the eyedropper hands the colour to the picker, + // which writes it to its own paint (a stroke, an effect...): the fill stays as it was. + let mut s = Session::new(); + s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap(); + rect(&mut s, None, (0.0, 0.0, 100.0, 100.0), "#FF0000"); + let b = rect(&mut s, None, (200.0, 0.0, 50.0, 50.0), "#00FF00"); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let undo = s.undo_depth().0; + s.execute("edit.pickColor", &json!({"apply": false})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap(); + assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into())); + assert_eq!(s.undo_depth().0, undo, "nothing to undo"); + assert_eq!(s.tool.picked.take().map(|c| c.to_hex()), Some("FF0000".into())); + assert_eq!(s.tool.tool, ToolId::Move, "picking ends the eyedropper"); + // The I key's eyedropper fills the selection again. + s.execute("edit.pickColor", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap(); + assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into())); + assert_eq!(s.tool.picked, None); +} + #[test] fn blend_modes_and_effects_through_commands() { let mut s = Session::new(); diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 6161f46b..d77a1bfc 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -9,7 +9,7 @@ pub fn run(cmd: &str, f: impl FnOnce() -> Result) -> Result { Err(payload) => { let msg = payload .downcast_ref::<&str>() - .map(|s| s.to_string()) + .map(ToString::to_string) .or_else(|| payload.downcast_ref::().cloned()) .unwrap_or_else(|| "panic".to_string()); log::error!("command `{cmd}` panicked: {msg}"); diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index a6973f01..d30f5f51 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -135,6 +135,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if !n.exports.is_empty() { o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null)); } + if let Some(b) = &n.board { + o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null)); + } if !n.annotations.is_empty() { o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null)); } @@ -312,6 +315,7 @@ pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value { doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect(); json!({ "name": doc.name, + "kind": doc.kind.id(), "path": ds.path, "dirty": ds.is_dirty(), "currentPage": ds.page, diff --git a/crates/engine/src/instance_modes_tests.rs b/crates/engine/src/instance_modes_tests.rs index 79007622..43de3ca4 100644 --- a/crates/engine/src/instance_modes_tests.rs +++ b/crates/engine/src/instance_modes_tests.rs @@ -126,3 +126,71 @@ fn detaching_keeps_the_mode_it_took_from_its_main() { assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B"); assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1); } + +/// A "Button" holding an instance of a swatch "Icon" on mode A, and a "Modal" holding a Button +/// instance whose icon copy was set to mode B: (the Button main, its icon layer, the copy in Modal). +fn modal(s: &mut Session) -> (NodeId, NodeId, NodeId) { + tone(s); + let icon = swatch(s, "Icon", None); + let frame = id(run(s, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); + let button_icon = id(run(s, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": frame}))); + set_mode(s, button_icon, json!("A")); + let button = id(run(s, "component.create", json!({"ids": [frame]}))); + let outer = id(run(s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); + let close = id(run(s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": outer}))); + let nested = s.doc().unwrap().node(close).unwrap().children[0]; + set_mode(s, nested, json!("B")); + run(s, "component.create", json!({"ids": [outer]})); + (button, button_icon, nested) +} + +#[test] +fn a_mode_set_on_a_layer_inside_an_instance_is_an_override() { + let mut s = Session::new(); + let (button, button_icon, nested) = modal(&mut s); + assert_eq!(fill(&s, nested), "0000FF"); + // An edit the copies can't take as they are rebuilds the Button instance. + run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"})); + assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps the mode set on the copy"); + // The main's layer choosing another mode doesn't reach it either. + set_mode(&mut s, button_icon, json!("B")); + set_mode(&mut s, button_icon, Value::Null); + assert_eq!(fill(&s, nested), "0000FF"); + run(&mut s, "component.resetOverride", json!({"id": nested, "group": "modes"})); + assert_eq!(fill(&s, nested), "FF0000", "reset: the main's layer is on Auto (mode A)"); + assert!(s.doc().unwrap().node(nested).unwrap().var_modes.is_empty()); +} + +#[test] +fn a_mode_set_on_an_icon_inside_a_placed_instance_survives_main_edits() { + let mut s = Session::new(); + let (button, _, _) = modal(&mut s); + // A Button placed on the page, its icon recoloured through the icon's mode. + let placed = instance(&mut s, button); + let icon = s.doc().unwrap().node(placed).unwrap().children[0]; + set_mode(&mut s, icon, json!("B")); + assert!(s.doc().unwrap().node(icon).unwrap().overrides.contains("modes"), "it shows as an override"); + run(&mut s, "node.set", json!({"ids": [button], "layout": "vertical"})); + assert_eq!(fill(&s, icon), "0000FF", "a rebuild of the Button keeps it"); + run(&mut s, "component.resetInstance", json!({"ids": [placed]})); + assert_eq!(fill(&s, icon), "FF0000", "reset all brings back the main's mode"); +} + +#[test] +fn a_file_from_before_modes_were_overrides_keeps_the_modes_chosen_inside_its_instances() { + let mut s = Session::new(); + let (button, _, nested) = modal(&mut s); + // Saved before `modes` was an override group: the copy has its mode but no marker. + s.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes"); + let path = std::env::temp_dir().join(format!("figcraft-instance-modes-{}.figcraft", std::process::id())); + let p = path.to_string_lossy().into_owned(); + run(&mut s, "file.saveAs", json!({"path": p})); + let mut s = Session::new(); + run(&mut s, "file.open", json!({"path": p})); + let _ = std::fs::remove_file(&path); + let doc = s.doc().unwrap(); + assert!(doc.node(nested).unwrap().overrides.contains("modes"), "opening it marks the mode the copy chose"); + assert!(doc.nodes().filter(|n| n.id != nested).all(|n| !n.overrides.contains("modes")), "layers on their main's modes stay unmarked"); + run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"})); + assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps it"); +} diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index c5e3c334..6f0641db 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -193,6 +193,15 @@ pub static KEYS: &[Binding] = &[ k(Tools, "tool.text", &["T"]), k(Tools, "tool.comment", &["C"]), k(Tools, "tool.shapeBuilder", &["Shift+M"]), + // Board tools (on boards only), on keys nothing else uses. + k(Tools, "tool.sticky", &["Q"]), + k(Tools, "tool.shape", &["B"]), + k(Tools, "tool.connector", &["X"]), + k(Tools, "tool.marker", &["M"]), + k(Tools, "tool.highlighter", &["U"]), + k(Tools, "tool.eraser", &["E"]), + k(Tools, "tool.stamp", &["W"]), + k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one").both(), // View // The documentation gives both keys for the pixel grid in different places. k(View, "view.pixelGrid", &["Shift+'", "Cmd+'"]), @@ -220,8 +229,10 @@ pub static KEYS: &[Binding] = &[ k(View, "view.prevPage", &["PageUp"]), k(View, "view.nextPage", &["PageDown"]), // Zoom - k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+="]), - k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-"]), + // Each zoom-in key has its zoom-out twin on the key beside it: `-` alone zooms out, so `=` + // alone (the `+` key without Shift) zooms in (#973). + k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+=", "="]), + k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-", "Shift+Cmd+-"]), k(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"]), k(Zoom, "view.zoomFit", &["Shift+1"]), k(Zoom, "view.zoomSelection", &["Shift+2"]), diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs index 38775d3b..f624ecfb 100644 --- a/crates/engine/src/keymap_tests.rs +++ b/crates/engine/src/keymap_tests.rs @@ -168,6 +168,25 @@ fn display_per_platform() { assert_eq!(primary("polygon.none", mac), None); } +/// Each way of zooming in has its zoom-out twin on the key beside it, as the keys arrive from a US +/// keyboard (#973: `-` alone zoomed out but `=` alone did nothing, so zooming back in was stuck). +#[test] +fn zoom_keys_come_in_pairs() { + let none = Mods::default(); + let shift = mods(true, false, false, false); + let cmd = mods(false, false, true, false); + let shift_cmd = mods(true, false, true, false); + for p in PLATFORMS { + for (zoom_in, zoom_out, m) in [("Equals", "Minus", none), ("Equals", "Minus", cmd), ("Plus", "Minus", shift_cmd), ("Plus", "Minus", shift)] { + assert_eq!(id_for(p, Context::Canvas, zoom_in, m), Some("view.zoomIn"), "{zoom_in} {m:?} on {p:?}"); + assert_eq!(id_for(p, Context::Canvas, zoom_out, m), Some("view.zoomOut"), "{zoom_out} {m:?} on {p:?}"); + } + // The menus still show Cmd/Ctrl and `+`. + assert_eq!(primary("view.zoomIn", p), Some("Cmd+=")); + assert_eq!(primary("view.zoomOut", p), Some("Cmd+-")); + } +} + #[test] fn tool_keys_come_from_the_keymap() { assert_eq!(ToolId::Move.shortcut(), "V"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..2cd224e7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod al_keys; pub mod annotations; pub mod arc_handles; pub mod background; +pub mod board; pub mod booleans; pub mod branches; pub mod catalog; @@ -413,6 +414,7 @@ impl DocState { } doc.link_layer_text_styles(); cmd::styles::apply_paint_bindings_on_load(&mut doc); + components::mark_layer_modes(&mut doc); // Initial import settlement belongs to the saved baseline, not the user's first edit. let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0)); let doc = Arc::new(doc); @@ -936,6 +938,7 @@ impl Session { /// as it was. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + board::refuse_on_board(self, id)?; // Revert replaces the document's uid: refuse before ending previews or syncing branches. if id == "file.revert" && self.live().doc_uid.is_some() { return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into())); @@ -1394,7 +1397,9 @@ pub fn normalize(doc: &mut Document, before: &Document) { figcraft_layout::coherent_sizing(doc, id); } touched.extend(doc.take_dirty()); - // 1. Text auto-size. + // 1. Text auto-size (a sticky's note first takes the size its length calls for). + board::settle_text(doc, before, &touched); + touched.extend(doc.take_dirty()); for id in live(doc, &touched) { let Some(n) = doc.node(id) else { continue }; if let Some((w, h)) = figcraft_text::auto_size(n) @@ -1475,6 +1480,9 @@ pub fn normalize(doc: &mut Document, before: &Document) { } } touched.extend(doc.take_dirty()); + // 4b. Board connectors follow the layers they join; their labels sit at their middles. + board::settle_routes(doc, before, &touched); + touched.extend(doc.take_dirty()); // 5. Ready-for-dev layers whose contents changed show "Changed". mark_dev_changes(doc, before, &touched); doc.take_dirty(); @@ -1607,6 +1615,10 @@ mod batch_step_ref_tests; #[cfg(test)] mod binding_lists_tests; #[cfg(test)] +mod board_tests; +#[cfg(test)] +mod board_tools_tests; +#[cfg(test)] mod booleans_tests; #[cfg(test)] mod bound_alpha_tests; @@ -1653,6 +1665,10 @@ mod devfocus_tests; #[cfg(test)] mod devinspect_tests; #[cfg(test)] +mod devmode_behaviour_tests; +#[cfg(test)] +mod devmode_tail_tests; +#[cfg(test)] mod doc_urls_tests; #[cfg(test)] mod double_click_tests; @@ -1675,6 +1691,8 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] +mod fill_none_tests; +#[cfg(test)] mod finite_geometry_tests; #[cfg(test)] mod frame_guides_tests; @@ -1887,6 +1905,8 @@ mod variants_discovery_tests; #[cfg(test)] mod vector_behaviour_tests; #[cfg(test)] +mod vector_fill_rule_tests; +#[cfg(test)] mod vector_multi_behaviour_tests; #[cfg(test)] mod vector_offset_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 26863561..cc6c3856 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -339,7 +339,7 @@ pub fn publish(doc: &Document, prev: Option<&PublishedLibrary>, sel: &Selection, .map(|v| v.id.as_str()) .collect(); let (bound, mode_colls) = used_variables(doc, &units); - let var_ids = alias_closure(&doc.variables, offered_vars.iter().map(|v| v.to_string()).chain(bound)); + let var_ids = alias_closure(&doc.variables, offered_vars.iter().map(ToString::to_string).chain(bound)); snap.variables.variables = doc.variables.variables.iter().filter(|v| var_ids.contains(&v.id)).cloned().collect(); let coll_ids: BTreeSet<&str> = snap .variables @@ -491,6 +491,11 @@ impl Cache { None => Err(EngineError::Other(crate::library_server::NOT_FETCHED.into())), }; } + // The path comes from a document: a network share would be contacted (and on Windows get + // the opener's credentials) by the stat below. + if !craft_docpath::is_local(path) { + return Err(EngineError::Other(format!("{path}: a library is opened only from this computer"))); + } let stamp = stat(path); // Checked before the cache: the limit is the caller's. if let Ok(st) = &stamp @@ -553,11 +558,18 @@ fn stat(_path: &str) -> std::result::Result { Err(NO_FILES.into()) } +/// A library read from a file or a server, with the modes chosen inside its instances marked when +/// it was published before they were overrides ([`crate::components::mark_layer_modes`]). +pub(crate) fn settled(mut lib: PublishedLibrary) -> PublishedLibrary { + crate::components::mark_layer_modes(Arc::make_mut(&mut lib.doc)); + lib +} + #[cfg(not(target_arch = "wasm32"))] fn read_file(path: &str) -> std::result::Result { let f = crate::file_access::open(path).map_err(|e| e.to_string())?; match figcraft_format::read_library(std::io::BufReader::new(f)) { - Ok(Some(lib)) => Ok(lib), + Ok(Some(lib)) => Ok(settled(lib)), Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")), Err(e) => Err(format!("{path}: {e}")), } diff --git a/crates/engine/src/library_path_tests.rs b/crates/engine/src/library_path_tests.rs index f15105dc..de40128a 100644 --- a/crates/engine/src/library_path_tests.rs +++ b/crates/engine/src/library_path_tests.rs @@ -28,3 +28,12 @@ fn a_local_library_path_is_still_followed() { let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist"); assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}"); } + +#[test] +fn the_library_cache_refuses_a_path_off_this_computer() { + let mut cache = crate::library::Cache::default(); + for path in [r"\\attacker.example\share\lib.figcraft", "//attacker.example/share/lib.figcraft"] { + let e = cache.load(path).expect_err(&format!("{path} must be refused")); + assert!(e.to_string().contains("only from this computer"), "{path}: {e}"); + } +} diff --git a/crates/engine/src/library_server.rs b/crates/engine/src/library_server.rs index 99d5f29a..10852b5f 100644 --- a/crates/engine/src/library_server.rs +++ b/crates/engine/src/library_server.rs @@ -53,7 +53,7 @@ pub fn archive(lib: &PublishedLibrary) -> Result> { /// The library in an archive from a server (untrusted: read with the library file's limits). pub fn read_archive(bytes: &[u8]) -> std::result::Result { match figcraft_format::read_library(Cursor::new(bytes)) { - Ok(Some(lib)) => Ok(lib), + Ok(Some(lib)) => Ok(crate::library::settled(lib)), Ok(None) => Err("the server sent a file without a library".into()), Err(e) => Err(format!("the server sent a library that can't be read ({e})")), } diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index 4fc778a7..b4a4171a 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -344,3 +344,67 @@ fn library_style_bindings_ignore_unrelated_local_variables() { healthy(&s); let _ = std::fs::remove_dir_all(&d); } + +/// A library "Modal" holding a Button instance whose icon copy is on mode Dark: (the Button main, +/// the icon copy in Modal). +fn nested_mode_kit(lib: &mut Session) -> (NodeId, NodeId) { + theme(lib); + let f = idv(&ex(lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"}))); + ex(lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"})); + let icon = idv(&ex(lib, "component.create", json!({"ids": [f]}))); + let b = idv(&ex(lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); + ex(lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b})); + let button = idv(&ex(lib, "component.create", json!({"ids": [b]}))); + let m = idv(&ex(lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); + let close = idv(&ex(lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m}))); + let nested = lib.doc().unwrap().node(close).unwrap().children[0]; + ex(lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"})); + ex(lib, "component.create", json!({"ids": [m]})); + (button, nested) +} + +/// The icon inside the Button inside library Modal instance `inst`. +fn modal_icon(s: &Session, inst: NodeId) -> NodeId { + let doc = s.doc().unwrap(); + doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0] +} + +#[test] +fn modes_set_inside_nested_instances_come_along() { + let d = dir("nested-modes"); + let lib_path = path_str(&d.join("kit.figcraft")); + let mut lib = Session::new(); + let (button, _) = nested_mode_kit(&mut lib); + let lid = publish_to(&mut lib, &lib_path); + + let mut s = Session::new(); + ex(&mut s, "library.add", json!({"path": lib_path})); + let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0}))); + let copy = |s: &Session| modal_icon(s, inst); + assert_eq!(fill(&s, copy(&s)), "FFFFFF", "the Dark mode set on the nested icon comes along"); + // An update that rebuilds the Button copies keeps it too. + ex(&mut lib, "node.set", json!({"ids": [button], "layout": "horizontal"})); + republish(&mut lib); + ex(&mut s, "library.acceptUpdates", json!({})); + assert_eq!(fill(&s, copy(&s)), "FFFFFF"); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_nested_instances() { + let d = dir("nested-modes-old"); + let lib_path = path_str(&d.join("kit.figcraft")); + let mut lib = Session::new(); + let (_, nested) = nested_mode_kit(&mut lib); + // Published before `modes` was an override group: the copy has its mode but no marker. + lib.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes"); + let lid = publish_to(&mut lib, &lib_path); + + let mut s = Session::new(); + ex(&mut s, "library.add", json!({"path": lib_path})); + let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0}))); + assert_eq!(fill(&s, modal_icon(&s, inst)), "FFFFFF", "the Dark mode comes along"); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/nested_override_tests.rs b/crates/engine/src/nested_override_tests.rs index f684a693..0d61edf7 100644 --- a/crates/engine/src/nested_override_tests.rs +++ b/crates/engine/src/nested_override_tests.rs @@ -198,3 +198,179 @@ fn copied_instances_keep_their_overrides() { assert!(node(&s, t).source.is_none() && node(&s, t).overrides.is_empty()); healthy(&s); } + +/// `button()` with the main's copy of the icon's Shape filled red, and an instance-swap property +/// `Icon` on the nested icon: (icon, shape, button, nested). +fn button_with_icon_override(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let (icon_main, shape, button, nested) = button(s); + let main_shape = copy_of(s, nested, shape); + ex(s, "node.set", json!({"id": main_shape, "props": {"fill": "#ff0000"}})); + ex(s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": icon_main, "layer": nested})); + (icon_main, shape, button, nested) +} + +/// Swap instance `i`'s icon to `to` by its instance-swap property `Icon`. +fn swap_icon(s: &mut Session, i: NodeId, to: NodeId) { + ex(s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": to}})); + healthy(s); +} + +#[test] +fn a_main_override_carries_onto_a_swapped_nested_instance() { + // Documented: overrides survive a swap when layer names match, and a swapped icon takes the + // colour the main component set (forum thread with staff replies). Rows: by the instance-swap + // property and by instance.swap, onto a matching and an unmatched name. The main's size + // override is geometry and stays out; the instance's own override wins over the main's. + let mut s = Session::new(); + let (_icon, shape, button, nested) = button_with_icon_override(&mut s); + let main_shape = copy_of(&s, nested, shape); + ex(&mut s, "node.set", json!({"id": main_shape, "props": {"width": 10}})); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let unmatched = icon(&mut s, 300.0, "Unmatched", "Glyph"); + for (by_property, to, want) in [(true, other, "FF0000"), (false, other, "FF0000"), (true, unmatched, "111111"), (false, unmatched, "111111")] { + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + if by_property { + swap_icon(&mut s, i, to); + } else { + let inested = copy_of(&s, i, nested); + ex(&mut s, "instance.swap", json!({"ids": [inested], "component": to})); + healthy(&s); + } + let glyph = kids(&s, copy_of(&s, i, nested))[0]; + assert_eq!(fill(&s, glyph), want, "by property {by_property}, to {to}"); + assert_eq!(node(&s, glyph).width, 20.0, "the main's size override stays out"); + } + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 600}))); + let glyph = kids(&s, copy_of(&s, i, nested))[0]; + ex(&mut s, "node.set", json!({"id": glyph, "props": {"fill": "#0000ff"}})); + swap_icon(&mut s, i, other); + assert_eq!(fill(&s, kids(&s, copy_of(&s, i, nested))[0]), "0000FF", "the instance's own override wins"); +} + +#[test] +fn a_main_override_carries_onto_a_swap_one_level_deeper() { + // As above for a Button instance inside a Toolbar main, swapped in a Toolbar instance. + let mut s = Session::new(); + let (_icon, _shape, button, nested) = button_with_icon_override(&mut s); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let bar = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 300, "height": 60, "name": "Toolbar"}))); + let inner = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": bar}))); + let toolbar = idv(&ex(&mut s, "component.create", json!({"ids": [bar]}))); + let t = idv(&ex(&mut s, "instance.create", json!({"component": toolbar, "x": 0, "y": 800}))); + let icon_in_t = copy_of(&s, t, copy_of(&s, inner, nested)); + ex(&mut s, "instance.swap", json!({"ids": [icon_in_t], "component": other})); + healthy(&s); + assert_eq!(fill(&s, kids(&s, icon_in_t)[0]), "FF0000"); +} + +#[test] +fn a_swapped_nested_instance_keeps_the_main_override_it_took() { + // Documented (staff reply): later edits of the main's icon colour don't reach instances that + // swapped, and do reach the rest. Undo and redo follow; resetting the override shows the icon + // main's own fill. + let mut s = Session::new(); + let (_icon, shape, button, nested) = button_with_icon_override(&mut s); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let swapped = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + let kept = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 500}))); + swap_icon(&mut s, swapped, other); + let glyph = |s: &Session, i: NodeId| fill(s, kids(s, copy_of(s, i, nested))[0]); + let main_shape = copy_of(&s, nested, shape); + ex(&mut s, "node.set", json!({"id": main_shape, "props": {"fill": "#00ff00"}})); + healthy(&s); + assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into())); + s.undo().unwrap(); + assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "FF0000".into())); + s.redo().unwrap(); + assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into())); + let swapped_glyph = kids(&s, copy_of(&s, swapped, nested))[0]; + ex(&mut s, "component.resetOverride", json!({"id": swapped_glyph, "group": "fills"})); + healthy(&s); + assert_eq!(glyph(&s, swapped), "111111"); +} + +#[test] +fn a_swapped_in_icon_keeps_its_own_overrides_to_itself() { + // Docs silent; pinned: what an icon main overrides inside itself stays that main's. After a + // swap to it, its edits still reach the instance; a second swap doesn't carry them on. + let mut s = Session::new(); + let (_icon, _shape, button, nested) = button_with_icon_override(&mut s); + let dot = icon(&mut s, 200.0, "Dot", "Shape"); + let with_dot = |s: &mut Session, x: f64, name: &str, fill: Option<&str>| { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 300, "width": 24, "height": 24, "name": name}))); + let d = idv(&ex(s, "instance.create", json!({"component": dot, "x": 0, "y": 0, "parent": f}))); + if let Some(c) = fill { + let shape = kids(s, d)[0]; + ex(s, "node.set", json!({"id": shape, "props": {"fill": c}})); + } + (idv(&ex(s, "component.create", json!({"ids": [f]}))), d) + }; + let (other2, dot2) = with_dot(&mut s, 300.0, "Other2", Some("#0000ff")); + let (other3, _) = with_dot(&mut s, 400.0, "Other3", None); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + swap_icon(&mut s, i, other2); + let shape_in = |s: &Session| kids(s, kids(s, copy_of(s, i, nested))[0])[0]; + assert_eq!(fill(&s, shape_in(&s)), "0000FF"); + let shape2 = kids(&s, dot2)[0]; + ex(&mut s, "node.set", json!({"id": shape2, "props": {"fill": "#00ffff"}})); + healthy(&s); + assert_eq!(fill(&s, shape_in(&s)), "00FFFF", "Other2's own edit reaches it"); + swap_icon(&mut s, i, other3); + assert_eq!(fill(&s, shape_in(&s)), "111111", "Other2's override doesn't carry onto Other3"); +} + +#[test] +fn a_swapped_out_icons_own_overrides_stay_with_it() { + // Docs silent; pinned: what the Button's default icon main overrides inside itself isn't the + // Button main's to carry. Icon A colours its Base's Shape green; swapping to icon B, also built + // on Base, shows Base's own fill. + let mut s = Session::new(); + let base = icon(&mut s, 0.0, "Base", "Shape"); + let on_base = |s: &mut Session, x: f64, name: &str| { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 500, "width": 24, "height": 24, "name": name}))); + let b = idv(&ex(s, "instance.create", json!({"component": base, "x": 0, "y": 0, "parent": f}))); + (idv(&ex(s, "component.create", json!({"ids": [f]}))), b) + }; + let (a, a_base) = on_base(&mut s, 100.0, "A"); + let (b, _) = on_base(&mut s, 200.0, "B"); + let a_shape = kids(&s, a_base)[0]; + ex(&mut s, "node.set", json!({"id": a_shape, "props": {"fill": "#00ff00"}})); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 700, "width": 40, "height": 40, "name": "Button"}))); + let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 4, "y": 4, "parent": f}))); + let button = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + ex(&mut s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": a, "layer": nested})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 0, "y": 800}))); + swap_icon(&mut s, i, b); + let shape = kids(&s, kids(&s, copy_of(&s, i, nested))[0])[0]; + assert_eq!(fill(&s, shape), "111111"); + assert!(node(&s, shape).overrides.is_empty()); +} + +#[test] +fn a_slot_the_main_filled_shows_the_swapped_in_mains_content() { + // Docs silent on slot content across a swap; pinned: a Chip the Panel main put in its card's + // slot doesn't carry onto the swapped-in card, which shows its own default content. + let mut s = Session::new(); + let card = |s: &mut Session, x: f64, name: &str| { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 60, "name": name}))); + let content = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 60, "parent": f, "name": "Content"}))); + ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": content, "props": {"name": format!("{name} default")}})); + let c = idv(&ex(s, "component.create", json!({"ids": [f]}))); + ex(s, "component.makeSlot", json!({"id": content})); + c + }; + let (a, b) = (card(&mut s, 0.0, "A"), card(&mut s, 200.0, "B")); + let chip = icon(&mut s, 400.0, "Chip", "Dot"); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 200, "height": 100, "name": "Panel"}))); + let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 0, "y": 0, "parent": f}))); + let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + ex(&mut s, "instance.slotInsert", json!({"id": nested, "slot": "Content", "component": chip, "replace": true})); + ex(&mut s, "component.addProperty", json!({"id": panel, "name": "Card", "type": "instanceSwap", "default": a, "layer": nested})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 0, "y": 700}))); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Card": b}})); + healthy(&s); + let content = kids(&s, copy_of(&s, i, nested))[0]; + let names: Vec = kids(&s, content).into_iter().map(|k| node(&s, k).name.clone()).collect(); + assert_eq!(names, ["B default"]); + assert!(!node(&s, content).overrides.contains(SLOT)); +} diff --git a/crates/engine/src/pen_overlay_tests.rs b/crates/engine/src/pen_overlay_tests.rs index 7e0064d2..7f6dba3c 100644 --- a/crates/engine/src/pen_overlay_tests.rs +++ b/crates/engine/src/pen_overlay_tests.rs @@ -63,3 +63,24 @@ fn finishing_the_path_clears_its_handles_and_band() { let ov = &s.tool.overlay; assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none()); } + +#[test] +fn shift_keeps_the_band_and_the_next_point_at_45_degree_steps() { + // #970: with Shift the band shows where the point will land, at a 45° step from the last + // anchor (the same length as the pointer is away), not under the pointer. + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + at(&mut s, PointerKind::Down, 100.0, 100.0); + at(&mut s, PointerKind::Up, 100.0, 100.0); + let shifted = |kind, x, y| PointerEvent { shift: true, ..PointerEvent::at(kind, x, y, 1.0) }; + s.pointer(&shifted(PointerKind::Hover, 300.0, 120.0)).unwrap(); + let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap(); + assert_eq!([x0, y0, y1], [100.0, 100.0, 100.0], "level with the last anchor"); + assert!((x1 - (100.0 + 200f64.hypot(20.0))).abs() < 1e-9, "{x1}"); + s.pointer(&shifted(PointerKind::Down, 300.0, 120.0)).unwrap(); + s.pointer(&shifted(PointerKind::Up, 300.0, 120.0)).unwrap(); + s.pointer(&shifted(PointerKind::Hover, 400.0, 230.0)).unwrap(); + let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap(); + assert_eq!(y0, 100.0, "the point landed on the band"); + assert!((x1 - x0 - (y1 - y0)).abs() < 1e-9, "45° down to the right: {:?}", [x0, y0, x1, y1]); +} diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index be997079..b7c12227 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -107,6 +107,8 @@ const DENIED: &[&str] = &[ "plugins.", "libraries.", "library.add", + // Uploads the open document to a server. + "library.publishToServer", "edit.undo", "edit.redo", "edit.repeat", @@ -132,6 +134,9 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { if command == "image.place" && get(params, "path").is_some() { return Some("plugins can't read files: pass the image as `data`".into()); } + if command == "text.loadFont" && get(params, "path").is_some() { + return Some("plugins can't read files: pass the font as `data`".into()); + } // `path` here is a file on disk (elsewhere it is a vector path or an action's place). if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() { return Some("plugins can't write files: leave out `path` to get the result back".into()); @@ -155,7 +160,8 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { /// Whether `params` set layer data (`pluginData`, anywhere in them) under a widget's own keys. fn writes_widget_data(params: &Value, depth: usize) -> bool { let own = |data: &Value| data.as_object().is_some_and(|m| m.keys().any(|k| k.starts_with(crate::widgets::DATA_PREFIX))); - depth < 64 + // serde_json refuses nesting past 128, so nothing deeper can reach here. + depth < 128 && match params { Value::Object(m) => m.iter().any(|(k, v)| (k == "pluginData" && own(v)) || writes_widget_data(v, depth + 1)), Value::Array(a) => a.iter().any(|v| writes_widget_data(v, depth + 1)), @@ -592,3 +598,28 @@ pub fn specs() -> Vec { spec!("plugins.manage", "Manage plugins…", "{} opens the plugins window; returns the list like `plugin.list`", false, manage), ] } + +#[cfg(test)] +mod refusal_tests { + use super::*; + + #[test] + fn read_only_plugin_cannot_publish_to_server() { + let m = Manifest::parse(r#"{"id":"x.y","name":"X","main":"plugin.wasm"}"#).unwrap(); + let r = refusal(&m, "library.publishToServer", &json!({})); + assert!(r.is_some(), "library.publishToServer uploads the document and must be refused"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn widget_data_is_found_at_any_depth_serde_accepts() { + let depth = 100; + let text = format!("{}{{\"pluginData\":{{\"{}x\":1}}}}{}", "{\"a\":".repeat(depth), crate::widgets::DATA_PREFIX, "}".repeat(depth)); + let v: Value = serde_json::from_str(&text).unwrap(); + assert!(writes_widget_data(&v, 0)); + } +} diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 657b6825..7f6c3740 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -164,6 +164,7 @@ fn files_dialogs_history_and_plugins_are_off_limits() { ("file.saveAs", json!({"path": target})), ("file.open", json!({"path": "C:/Windows/win.ini"})), ("image.place", json!({"path": "secret.png"})), + ("text.loadFont", json!({"path": "/etc/hosts"})), ("library.add", json!({"path": "lib.figcraft"})), ("plugin.run", json!({"id": "org.test.deny"})), ("plugin.remove", json!({"id": "org.test.deny"})), diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs index d635808e..fc4ca775 100644 --- a/crates/engine/src/save_format_tests.rs +++ b/crates/engine/src/save_format_tests.rs @@ -15,8 +15,12 @@ struct SaveFiles { impl SaveFiles { fn new() -> Self { + // Tests run in parallel and the clock can read the same for two of them (macOS counts + // microseconds), so a counter keeps their folders apart. + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id())); + let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}-{n}", std::process::id())); std::fs::create_dir(&dir).unwrap(); Self { dir, files: Vec::new() } } diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index bc961a4a..ef5869b2 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -324,6 +324,49 @@ fn shift_drag_moves_the_selection() { assert_eq!(s.selection(), vec![k.empty], "Shift let go before the button still takes the layer out"); } +#[test] +fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() { + let mut s = Session::new(); + let k = scene(&mut s); + for i in [k.r, k.c] { + ex(&mut s, "node.set", json!({"ids": [i], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}})); + } + let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin(); + // (selection before, drag from, selection after, the layers that move by (10, 10), derivation) + let rows: Vec<(Vec, (f64, f64), Vec, Vec, &str)> = vec![ + (vec![k.r], (520.0, 20.0), vec![k.r], vec![k.r], "a selected unfilled shape drags from its inside, not only its stroke"), + (vec![k.c], (40.0, 140.0), vec![k.c], vec![k.c], "the same in a top-level frame, whose background is under the inside"), + (vec![k.r, k.empty], (570.0, 20.0), vec![k.r, k.empty], vec![k.r, k.empty], "a gap inside a multiple selection's box drags them all"), + (vec![], (520.0, 20.0), vec![k.r], vec![], "an unselected unfilled shape is hit on its stroke only: the drag is a marquee, which touches it"), + ]; + for (before, from, after, moved, why) in rows { + let home: Vec = [k.r, k.c, k.empty].iter().map(|i| at(&s, *i)).collect(); + ex(&mut s, "select.set", json!({"ids": before})); + drag(&mut s, from, (from.0 + 10.0, from.1 + 10.0), NONE); + assert_eq!(sorted(s.selection()), sorted(after), "{why}"); + for (i, h) in [k.r, k.c, k.empty].iter().zip(&home) { + let d = if moved.contains(i) { 10.0 } else { 0.0 }; + assert_eq!(at(&s, *i), Point::new(h.x + d, h.y + d), "{why}"); + } + if !moved.is_empty() { + assert!(s.undo().unwrap(), "{why}: one undo step"); + } + } + // A turned layer's box is turned with it: the corners of its page-aligned bounds are outside. + ex(&mut s, "node.set", json!({"ids": [k.r], "props": {"rotation": 45}})); + let b = s.doc().unwrap().world_bounds(k.r).unwrap(); + ex(&mut s, "select.set", json!({"ids": [k.r]})); + drag(&mut s, (b.x0 + 2.0, b.y0 + 2.0), (b.x0 + 12.0, b.y0 + 12.0), NONE); + assert_eq!(at(&s, k.r), b.origin(), "a press outside a turned layer's box is a marquee: it doesn't move the layer"); + ex(&mut s, "select.set", json!({"ids": [k.r]})); + let c = b.center(); + drag(&mut s, (c.x, c.y), (c.x + 10.0, c.y + 10.0), NONE); + assert_eq!(s.selection(), vec![k.r], "inside the turned box it drags"); + // Pixel snapping rounds the turned box's origin, so the move is about the pointer's. + let d = at(&s, k.r) - b.origin(); + assert!((d.x - 10.0).abs() < 2.0 && (d.y - 10.0).abs() < 2.0, "it moves with the pointer: {d:?}"); +} + #[test] fn alt_shift_drag_on_a_selected_layer_duplicates_it() { let mut s = Session::new(); diff --git a/crates/engine/src/shortcut_parity_tests.rs b/crates/engine/src/shortcut_parity_tests.rs index cf7d5d47..cb2faf13 100644 --- a/crates/engine/src/shortcut_parity_tests.rs +++ b/crates/engine/src/shortcut_parity_tests.rs @@ -120,6 +120,15 @@ static ROWS: &[Row] = &[ pin(Tools, "tool.slice", &["S"], &[]), pin(Tools, "file.placeImage", &["Shift+Cmd+K"], &[]), pin(Tools, "tool.shapeBuilder", &["Shift+M"], &[]), + // Board tools (boards only), on keys no design-file shortcut uses. + pin(Tools, "tool.sticky", &["Q"], &[]), + pin(Tools, "tool.shape", &["B"], &[]), + pin(Tools, "tool.connector", &["X"], &[]), + pin(Tools, "tool.marker", &["M"], &[]), + pin(Tools, "tool.highlighter", &["U"], &[]), + pin(Tools, "tool.eraser", &["E"], &[]), + pin(Tools, "tool.stamp", &["W"], &[]), + pin(Tools, "board.nextSticky", &["Shift+Cmd+Enter"], &[]), // Vector edit mode doc(Selection, "Edit the selected shape's points", &["Enter"], &[], Bound("select.children"), "Enter on a shape edits it; on a frame or group it selects the children"), doc(Shape, "Vector edit: move", &["V"], &[], VectorTool(VTool::Move), ""), @@ -162,8 +171,8 @@ static ROWS: &[Row] = &[ doc(Zoom, "Zoom out", &["Cmd+-", "-"], &[], Bound("view.zoomOut"), ""), doc(Zoom, "Zoom to fit", &["Shift+1"], &[], Bound("view.zoomFit"), ""), doc(Zoom, "Zoom to the selection", &["Shift+2"], &[], Bound("view.zoomSelection"), ""), - pin(Zoom, "view.zoomIn", &["Shift+Cmd+="], &[]), - pin(Zoom, "view.zoomOut", &["Shift+-"], &[]), + pin(Zoom, "view.zoomIn", &["Shift+Cmd+=", "="], &[]), + pin(Zoom, "view.zoomOut", &["Shift+-", "Shift+Cmd+-"], &[]), pin(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"], &[]), pin(Zoom, "view.prevFrame", &["Shift+N", "Home"], &[]), pin(Zoom, "view.nextFrame", &["N", "End"], &[]), @@ -398,7 +407,7 @@ fn counts_match_the_docs() { let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count(); let pinned = ROWS.len() - documented.len(); // docs/ui-parity.md quotes these. - assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 49)); + assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 57)); } #[test] diff --git a/crates/engine/src/styles_behaviour_tests.rs b/crates/engine/src/styles_behaviour_tests.rs index ff7af50b..de9da5a5 100644 --- a/crates/engine/src/styles_behaviour_tests.rs +++ b/crates/engine/src/styles_behaviour_tests.rs @@ -80,6 +80,20 @@ fn link(s: &Session, n: NodeId, kind: &str) -> Option { } } +/// The key linking a style of `kind` (a paint style links fills). +fn style_key(kind: &str) -> String { + if kind == "paint" { "fillStyle".into() } else { format!("{kind}Style") } +} + +/// `node.create` params for an unselected layer that takes `kind` styles, with `props`. +fn new_layer(kind: &str, props: Value) -> Value { + if kind == "text" { + json!({"type": "text", "text": "Hej", "select": false, "props": props}) + } else { + json!({"type": "frame", "select": false, "props": props}) + } +} + #[test] fn a_style_holds_the_whole_property_and_links_its_source() { // Documented: colour styles can hold several fills (gradients, images); effect styles combine @@ -234,6 +248,59 @@ fn editing_a_styled_property_detaches_that_style_only() { let _ = st; } +#[test] +fn a_style_link_wins_over_values_in_the_same_call() { + // Docs silent on one call setting both (a linked layer shows its style's values; editing one + // detaches it); pinned: the link wins whatever the keys are called, on node.create and + // node.set, and a deviation set in a later call unlinks as before. + let mut s = Session::new(); + let rows = styled(&mut s); + let pairs: [(&str, &str, Value); 12] = [ + ("paint", "fill", json!("#123456")), + ("paint", "fills", json!(["#123456"])), + ("stroke", "stroke", json!("#123456")), + ("stroke", "strokes", json!(["#123456"])), + ("effect", "effects", json!([])), + ("grid", "grids", json!([])), + ("grid", "layoutGrids", json!([])), + ("text", "fontStyle", json!("Bold")), + ("text", "lineHeight", json!(40)), + ("text", "textDecoration", json!("underline")), + ("text", "letterSpacing", json!(5)), + ("text", "variationSettings", json!({"wght": 700})), + ]; + for (kind, key, value) in pairs { + let (source, st, _) = rows.iter().find(|r| r.2 == kind).unwrap().clone(); + let link_key = style_key(kind); + let props = json!({key: value.clone(), link_key.as_str(): st.clone()}); + let made = id(run(&mut s, "node.create", new_layer(kind, props.clone()))); + let set = if kind == "text" { text(&mut s, 400.0) } else { rect(&mut s, 400.0) }; + run(&mut s, "node.set", json!({"id": set, "props": props})); + for (how, layer) in [("node.create", made), ("node.set", set)] { + assert_eq!(link(&s, layer, kind).as_deref(), Some(st.as_str()), "{how} `{key}` + `{link_key}`: linked"); + assert_eq!(shown(&s, layer, kind), shown(&s, source, kind), "{how} `{key}` + `{link_key}`: the style's values"); + } + run(&mut s, "node.set", json!({"id": set, "props": {key: value}})); + assert_eq!(link(&s, set, kind), None, "`{key}` in a later call unlinks"); + assert_ne!(shown(&s, set, kind), shown(&s, source, kind), "`{key}` in a later call applies"); + } +} + +#[test] +fn a_style_link_leaves_values_its_style_does_not_set() { + // Docs silent; pinned: alignment given beside a text style applies, on node.create and node.set. + let mut s = Session::new(); + let st = styled(&mut s).into_iter().find(|r| r.2 == "text").unwrap().1; + let props = json!({"textStyle": st, "textAlign": "center"}); + let made = id(run(&mut s, "node.create", new_layer("text", props.clone()))); + let set = text(&mut s, 400.0); + run(&mut s, "node.set", json!({"id": set, "props": props})); + for (how, layer) in [("node.create", made), ("node.set", set)] { + assert_eq!(link(&s, layer, "text").as_deref(), Some(st.as_str()), "{how}: linked"); + assert_eq!(node(&s, layer).kind.text().unwrap().align_h, figcraft_doc::TextAlignH::Center, "{how}: `textAlign` applies"); + } +} + #[test] fn applying_a_style_replaces_variables_bound_to_that_property() { // Documented: applying a style sets the property to the style's values. Docs silent on bound diff --git a/crates/engine/src/synthetic.rs b/crates/engine/src/synthetic.rs index c2a9f5d6..d5ead671 100644 --- a/crates/engine/src/synthetic.rs +++ b/crates/engine/src/synthetic.rs @@ -28,6 +28,53 @@ fn fill(doc: &mut Document, id: NodeId, c: Color) { } } +fn add_screen_header(doc: &mut Document, screen: NodeId, s: usize) -> DocResult<()> { + let header = doc.create(screen, NodeKind::Frame, "Header", 0.0, 0.0, SCREEN_W, 40.0)?; + if let Some(n) = doc.node_mut(header) { + n.layout = Some(AutoLayout { gap: 8.0, padding: [8.0; 4], ..AutoLayout::default() }); + n.fills = vec![Paint::solid(Color::from_rgb8_const(0xF5, 0xF5, 0xF5))]; + } + doc.create(header, text(&format!("Screen {}", s + 1)), "Title", 0.0, 0.0, 120.0, 16.0)?; + for k in 0..2 { + let icon = doc.create(header, ellipse(), &format!("Icon {}", k + 1), 0.0, 0.0, 20.0, 20.0)?; + fill(doc, icon, Color::from_rgb8_const(0x99, 0x99, 0x99)); + } + Ok(()) +} + +fn add_card_group(doc: &mut Document, screen: NodeId) -> DocResult<()> { + let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?; + // Children are in the group's space; normalising fits the group around them. + let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?; + fill(doc, cbg, Color::from_rgb8_const(0xEE, 0xF4, 0xFF)); + doc.create(card, ellipse(), "Avatar", 8.0, 8.0, 32.0, 32.0)?; + doc.create(card, text("Ada Lovelace"), "Name", 48.0, 8.0, 100.0, 16.0)?; + doc.create(card, text("Analyst"), "Role", 48.0, 28.0, 100.0, 16.0)?; + Ok(()) +} + +fn add_screen_grid(doc: &mut Document, screen: NodeId, s: usize, rest: usize, made: &mut usize) -> DocResult<()> { + for k in 0..rest { + let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0); + let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0; + // The grid starts with a rectangle so even the smallest workload has one. + let kind = (k + 2) % 5; + let id = match kind { + 0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?, + 1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?, + _ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?, + }; + if kind != 0 { + fill(doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0)); + if let Some(n) = doc.node_mut(id) { + n.corners.radii = [3.0; 4]; + } + } + *made += 1; + } + Ok(()) +} + /// A page of about `layers` layers (pages not counted), normalised like an opened file. Returns /// the document and its page. pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { @@ -53,28 +100,13 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { let budget = |made: usize| layers.saturating_sub(made); // Auto layout header: title + two icons (4 layers). if budget(made) >= 4 { - let header = doc.create(screen, NodeKind::Frame, "Header", 0.0, 0.0, SCREEN_W, 40.0)?; - if let Some(n) = doc.node_mut(header) { - n.layout = Some(AutoLayout { gap: 8.0, padding: [8.0; 4], ..AutoLayout::default() }); - n.fills = vec![Paint::solid(Color::from_rgb8_const(0xF5, 0xF5, 0xF5))]; - } - doc.create(header, text(&format!("Screen {}", s + 1)), "Title", 0.0, 0.0, 120.0, 16.0)?; - for k in 0..2 { - let icon = doc.create(header, ellipse(), &format!("Icon {}", k + 1), 0.0, 0.0, 20.0, 20.0)?; - fill(&mut doc, icon, Color::from_rgb8_const(0x99, 0x99, 0x99)); - } + add_screen_header(&mut doc, screen, s)?; made += 4; } // A card group (5 layers). // The card and the instances leave one layer for the grid, which starts with a rectangle. if budget(made) > 5 { - let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?; - // Children are in the group's space; normalising fits the group around them. - let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?; - fill(&mut doc, cbg, Color::from_rgb8_const(0xEE, 0xF4, 0xFF)); - doc.create(card, ellipse(), "Avatar", 8.0, 8.0, 32.0, 32.0)?; - doc.create(card, text("Ada Lovelace"), "Name", 48.0, 8.0, 100.0, 16.0)?; - doc.create(card, text("Analyst"), "Role", 48.0, 28.0, 100.0, 16.0)?; + add_card_group(&mut doc, screen)?; made += 5; } // Three button instances (3 layers each). @@ -87,24 +119,7 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { } // Fill the rest of the screen with a grid of shapes and text. let rest = PER_SCREEN.saturating_sub(4 + 5 + 9 + 1).min(budget(made)); - for k in 0..rest { - let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0); - let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0; - // The grid starts with a rectangle so even the smallest workload has one. - let kind = (k + 2) % 5; - let id = match kind { - 0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?, - 1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?, - _ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?, - }; - if kind != 0 { - fill(&mut doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0)); - if let Some(n) = doc.node_mut(id) { - n.corners.radii = [3.0; 4]; - } - } - made += 1; - } + add_screen_grid(&mut doc, screen, s, rest, &mut made)?; if budget(made) == 0 { break; } diff --git a/crates/engine/src/templates/kit.rs b/crates/engine/src/templates/kit.rs index 4ad5b11a..39bd248b 100644 --- a/crates/engine/src/templates/kit.rs +++ b/crates/engine/src/templates/kit.rs @@ -216,7 +216,7 @@ impl Kit { pub fn variables(&mut self, collection: &str, modes: &[&str], vars: Vec<(&str, &str, Vec)>) -> Result<()> { self.run("variable.createCollection", json!({"name": collection, "modes": modes}))?; for (name, kind, values) in vars { - let values: Map = modes.iter().map(|m| m.to_string()).zip(values).collect(); + let values: Map = modes.iter().map(ToString::to_string).zip(values).collect(); self.run("variable.create", json!({"collection": collection, "name": name, "type": kind, "values": values}))?; } Ok(()) diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 84315f1f..b9a76fbc 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -177,20 +177,19 @@ fn vertical_trim_through_node_set() { #[test] fn paragraph_indent_only_moves_left_aligned_text() { + figcraft_text::scan_system_fonts(); let mut s = Session::new(); let t = text(&mut s, "Indented paragraph", json!({"fontSize": 20, "paragraphIndent": 30})); let first_x = |s: &Session| { let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap(); - (l.glyphs.first().map_or(0.0, |g| g.x), l.width) + (l.glyphs.first().map(|g| g.x), l.width) }; - let (x, w) = first_x(&s); - if w <= 0.0 { - return; - } + // Without any font there are no glyphs, but the indent alone still gives the box a width. + let (Some(x), w) = first_x(&s) else { return }; assert!((x - 30.0).abs() < 1e-6, "left: indented"); s.execute("node.set", &json!({"id": t, "props": {"textAlign": "center"}})).unwrap(); let (x, w2) = first_x(&s); - assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it"); + assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.is_some_and(|x| x.abs() < 1e-6), "centered auto-width text: no indent, the box shrinks by it"); } /// A new text made with the Text tool by a press at `from`, a drag to `to` and a release, holding diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 902ba149..85ea535b 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -32,7 +32,9 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) - pub fn end(s: &mut Session) -> Result<()> { let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) }; s.ds_mut()?.text_edit = None; - let empty = text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false); + // A board object's text (a sticky's note) stays when emptied; the object is selected again. + let owner = s.doc().ok().and_then(|d| crate::board::owner_of_text(d, te.id)); + let empty = owner.is_none() && text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false); if empty { let doc = s.doc_mut()?; let _ = doc.remove(te.id); @@ -50,6 +52,9 @@ pub fn end(s: &mut Session) -> Result<()> { } s.commit_interaction()?; let ds = s.ds_mut()?; + if let Some(o) = owner { + ds.selection = vec![o]; + } ds.revision += 1; Ok(()) } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f0115e1f..86feed4f 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -39,10 +39,18 @@ pub enum ToolId { Comment, Eyedropper, ShapeBuilder, + // Board tools. + Sticky, + BoardShape, + Connector, + Marker, + Highlighter, + Eraser, + Stamp, } impl ToolId { - pub const ALL: [ToolId; 18] = [ + pub const ALL: [ToolId; 25] = [ ToolId::Move, ToolId::Scale, ToolId::Hand, @@ -61,6 +69,13 @@ impl ToolId { ToolId::Comment, ToolId::Eyedropper, ToolId::ShapeBuilder, + ToolId::Sticky, + ToolId::BoardShape, + ToolId::Connector, + ToolId::Marker, + ToolId::Highlighter, + ToolId::Eraser, + ToolId::Stamp, ]; pub fn id(self) -> &'static str { match self { @@ -82,6 +97,13 @@ impl ToolId { ToolId::Comment => "comment", ToolId::Eyedropper => "eyedropper", ToolId::ShapeBuilder => "shapeBuilder", + ToolId::Sticky => "sticky", + ToolId::BoardShape => "shape", + ToolId::Connector => "connector", + ToolId::Marker => "marker", + ToolId::Highlighter => "highlighter", + ToolId::Eraser => "eraser", + ToolId::Stamp => "stamp", } } pub fn from_id(s: &str) -> Option { @@ -107,8 +129,23 @@ impl ToolId { ToolId::Comment => "Comment", ToolId::Eyedropper => "Pick color", ToolId::ShapeBuilder => "Shape builder", + ToolId::Sticky => "Sticky note", + ToolId::BoardShape => "Shapes", + ToolId::Connector => "Connector", + ToolId::Marker => "Marker", + ToolId::Highlighter => "Highlighter", + ToolId::Eraser => "Eraser", + ToolId::Stamp => "Stamp", } } + + /// The tools of a board's toolbar (a design file's toolbar has the others). + pub fn on_boards(self) -> bool { + matches!( + self, + ToolId::Sticky | ToolId::BoardShape | ToolId::Connector | ToolId::Marker | ToolId::Highlighter | ToolId::Eraser | ToolId::Stamp + ) + } /// The tool's key (from [`crate::keymap`]; empty when it has none). pub fn shortcut(self) -> &'static str { let id = self.id(); @@ -404,6 +441,15 @@ pub(crate) enum Gesture { }, /// Dragging the rotation origin handle (see [`crate::rotation_origin`]). Origin, + /// The connector tool: dragging from `from` (pressed at `start`). + Connect { + from: figcraft_doc::End, + start: Point, + }, + /// The eraser: the strokes it took so far. + Erase { + gone: Vec, + }, /// Shape builder: regions collected so far (handled in `shapebuilder`). ShapeBuild { picked: Vec, @@ -413,6 +459,13 @@ pub(crate) enum Gesture { }, } +/// Where Shift puts the Pen's next point: as far from `last` as `p` is, at the nearest 45° step. +fn at_45_degrees(last: Point, p: Point) -> Point { + let v = p - last; + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + last + Vec2::from_angle(ang) * v.hypot() +} + /// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in /// and out handles and the one before's out handle, those that aren't zero. fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> { @@ -447,8 +500,10 @@ pub struct ToolState { /// removes; Alt merges). pub shape_builder: Option>, pub builder_remove: bool, - /// Show distances from the selection on hover without holding Alt (Dev mode inspection). - pub redlines: bool, + /// Dev Mode: the canvas inspects. Hovering shows distances from the selection without Alt, and + /// presses select, pan and comment but never edit the design (no moving, resizing, drawing, + /// or editing text or vectors). + pub dev_mode: bool, /// Pen snap targets, kept while hovering (see `vectoredit::point_snap`). pub(crate) pen_targets: Option, /// The Scale tool's anchor: column and row (0 start, 1 middle, 2 end) of the point of the @@ -462,6 +517,13 @@ pub struct ToolState { pub labels: Vec<(NodeId, [Point; 4])>, /// The brush the Pencil draws with (`tool.pencilBrush`; `None`: a plain stroke). pub pencil_brush: Option, + /// The board tools' colours and choices. + pub board: crate::board::BoardTool, + /// The eyedropper samples for a colour picker (`edit.pickColor` with `apply: false`): the + /// colour goes to `picked`, for the picker to write to the paint it edits, not to the fill. + pub pick_only: bool, + /// The colour the eyedropper sampled for a colour picker, until the picker takes it. + pub picked: Option, } /// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most @@ -923,6 +985,7 @@ impl Session { } self.tool.tool = t; self.tool.gesture = Gesture::None; + self.tool.pick_only = false; // The tool's cursor shows before the pointer moves. self.tool.overlay = Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() }; @@ -940,6 +1003,21 @@ impl Session { } } + /// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects. + fn acting_tool(&self) -> ToolId { + match self.tool.tool { + t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move, + t => t, + } + } + + /// Drop the gesture under way (a drag, a resize, a shape being drawn), restoring the document + /// as Escape does; the UI calls it when Dev Mode starts, where nothing may move. + pub fn drop_gesture(&mut self) -> Result<()> { + self.tool.gesture = Gesture::None; + self.cancel_interaction() + } + /// Feed one pointer event to the current tool. pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> { // A held button sends the same move every frame: running the gesture again would only @@ -952,11 +1030,12 @@ impl Session { } self.tool.last_move = Some(ev.clone()); } - let tool = self.tool.tool; - if matches!(tool, ToolId::Move | ToolId::Scale) && self.origin_pointer(ev)? { + let tool = self.acting_tool(); + let edits = matches!(tool, ToolId::Move | ToolId::Scale) && !self.tool.dev_mode; + if edits && self.origin_pointer(ev)? { return Ok(()); } - if matches!(tool, ToolId::Move | ToolId::Scale) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { + if edits && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { return Ok(()); } if tool == ToolId::ShapeBuilder { @@ -987,6 +1066,7 @@ impl Session { Ok(()) } ToolId::Eyedropper => self.eyedrop(ev), + t if t.on_boards() => self.board_down(ev), t if t.draws_shape() => self.draw_down(ev), _ => Ok(()), }, @@ -1016,7 +1096,7 @@ impl Session { } fn hover(&mut self, ev: &PointerEvent) -> Result<()> { - let tool = self.tool.tool; + let tool = self.acting_tool(); let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); let mut cursor = tool.cursor(); @@ -1045,7 +1125,7 @@ impl Session { if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { cursor = Cursor::Text; } else if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() && shape_hover.is_none() { - if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) { + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| !self.tool.dev_mode) { cursor = resize_cursor(&doc, &sel, h); } else { let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); @@ -1054,10 +1134,10 @@ impl Session { } hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); // Alt over a selected layer: a drag from here duplicates it. - if ev.alt && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { + if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { cursor = Cursor::Copy; } - if ev.alt || self.tool.redlines { + if ev.alt || self.tool.dev_mode { use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of}; // Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the // selection to a ruler guide, the hovered layer or (over empty canvas) the parent. @@ -1085,6 +1165,8 @@ impl Session { guides = g; self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { + // With Shift the band ends where a click puts the point (#970). + let to = if ev.shift { at_45_degrees(*last, to) } else { to }; self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); } @@ -1138,6 +1220,7 @@ impl Session { textedit::end(self)?; } // Handles first: a selected ellipse's arc handles (drawn over the box), then the box's. + let edits = !self.tool.dev_mode; if self.arc_handles_shown() && let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom) && let Some(arc) = crate::arc_handles::arc_of(&doc, id) @@ -1157,7 +1240,7 @@ impl Session { self.tool.overlay.shape = shape_hint(&doc, id, handle, ev.zoom); return Ok(()); } - if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) { + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| edits) { let ids = roots(&doc, &sel); if let Handle::Rotate(_) = h { let Some(b) = selection_box(&doc, &ids) else { return Ok(()) }; @@ -1184,18 +1267,29 @@ impl Session { } // A frame's name label picks the frame itself. The background of a top-level frame with // layers in it is like the canvas (a press starts a marquee inside it) unless the frame is - // selected; an empty frame (or a widget) is picked and dragged. + // selected; an empty frame (or a widget, or a board object such as a sticky) is picked and + // dragged. let board_background = |leaf: NodeId| { !sel.contains(&leaf) - && doc - .node(leaf) - .is_some_and(|n| n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n)) + && doc.node(leaf).is_some_and(|n| { + n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom() + }) }; - let target = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| { + let label = label_at(&doc, page, &self.tool.labels, ev.p()); + let target = label.or_else(|| { self.hit(&doc, page, ev.p(), ev.tol() * 2.0) .filter(|leaf| ev.cmd || !board_background(*leaf)) .map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)) }); + // A press inside the selection's box that reaches nothing above it (an unfilled shape's + // inside, or the frame or canvas behind it) drags the selection. + let behind_sel = |t: Option| t.is_none_or(|t| sel.iter().any(|s| *s != t && doc.is_ancestor(t, *s))); + let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { + Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { + Some(first) + } + _ => target, + }; match target { Some(target) => { // Clicking inside the current selection's box keeps it (drag them all). @@ -1216,6 +1310,13 @@ impl Session { vec![target] }; self.set_selection(new_sel.clone()); + // Dev Mode only selects (Shift-click still deselects): nothing moves. + if !edits { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Ok(()); + } // Locked layers selected in the layers panel stay put. let ids: Vec = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect(); if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { @@ -1302,6 +1403,9 @@ impl Session { let idx = doc.index_in_parent(*i).map(|x| x + 1); copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?); } + // Connectors copied with what they join join the copies. + let map = crate::board::copy_map(doc, &ids, &copies); + crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false); moving = copies; *sel = moving.clone(); } @@ -1466,6 +1570,15 @@ impl Session { Ok(()) } Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()), + Gesture::Connect { from, start } => { + let (from, start) = (*from, *start); + self.connect_drag(ev, from, start); + return Ok(()); + } + Gesture::Erase { gone } => { + self.tool.gesture = Gesture::Erase { gone: gone.clone() }; + return self.erase_at(ev); + } Gesture::PenDrag { anchor } => { // A click's wobble pulls out no handle. let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor }; @@ -1842,10 +1955,17 @@ impl Session { self.tool.tool = t; } } - Gesture::Pencil { points, pressure } => { + Gesture::Pencil { mut points, mut pressure } => { self.tool.overlay.pen.clear(); + // The stroke ends where the button came up, even without a move to get there. + if points.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { + points.push(ev.p()); + pressure.push(ev.pressure); + } self.pencil_finish(&points, &pressure)?; } + Gesture::Connect { from, start } => self.connect_up(ev, from, start)?, + Gesture::Erase { .. } => self.erase_up()?, Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} Gesture::Vector(_) => self.commit_interaction()?, @@ -1862,9 +1982,11 @@ impl Session { if self.tool.tool == ToolId::Pen { return self.pen_finish(); } + let edits = !self.tool.dev_mode; // A frame's or section's name label: rename it there (the canvas puts a field over it). // Labels come before widgets, as on a press; the second press began a move: undo it. - if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && !(ev.shift || ev.cmd || ev.alt) && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) { @@ -1877,7 +1999,8 @@ impl Session { return Ok(()); } // Quick clicks on a widget's clickable part are clicks, one by one. - if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && !(ev.shift || ev.cmd || ev.alt) && self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some() { @@ -1893,9 +2016,13 @@ impl Session { self.cancel_interaction()?; } let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(&doc, leaf, &sel)? { + return Ok(()); + } let target = select_target(&doc, leaf, &sel, true); // Text: edit at the click. - if doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) + if edits + && doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) { // Entering a text selects all of it; in the text being edited, the word. @@ -1910,7 +2037,7 @@ impl Session { return Ok(()); } // Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active). - if doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) { + if edits && doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) { let mut ids = crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default(); ids.retain(|i| *i != target); ids.insert(0, target); @@ -2032,7 +2159,9 @@ impl Session { 255, ); let sel = ds.selection.clone(); - if !sel.is_empty() { + if self.tool.pick_only { + self.tool.picked = Some(c); + } else if !sel.is_empty() { self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?; } self.recent_colors.retain(|x| *x != c); @@ -2094,9 +2223,7 @@ impl Session { if ev.shift && let Some((last, _, _)) = pen.points.last() { - let v = p - *last; - let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; - p = *last + Vec2::from_angle(ang) * v.hypot(); + p = at_45_degrees(*last, p); } pen.points.push((p, Vec2::ZERO, Vec2::ZERO)); self.tool.gesture = Gesture::PenDrag { anchor: p }; @@ -2238,6 +2365,9 @@ impl Session { } let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default()); let d = fitted.to_svg(); + if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) { + return self.ink_finish(&d); + } let page = self.page()?; let parent = frame_at(self.doc()?, page, points.first().copied().unwrap_or_default(), &[]).unwrap_or(page); let pw = self.doc()?.world_transform(parent); @@ -2263,6 +2393,10 @@ impl Session { /// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point /// keys, Enter to finish the shape builder). Returns whether the key was used. pub fn tool_key(&mut self, name: &str, shift: bool, alt: bool, cmd: bool) -> Result { + // Dev Mode edits nothing: keys go to the commands (which it refuses in the app). + if self.tool.dev_mode { + return Ok(false); + } if self.ds().is_ok_and(|d| d.vector_edit.is_some()) { return crate::vectoredit::key(self, name, shift, alt, cmd); } @@ -2404,6 +2538,7 @@ impl Session { fn arc_handles_shown(&self) -> bool { let arc_drag = matches!(self.tool.gesture, Gesture::Arc { .. } | Gesture::Shape { .. }); self.tool.tool == ToolId::Move + && !self.tool.dev_mode && (arc_drag || !self.tool.busy()) && self.ds().is_ok_and(|ds| ds.vector_edit.is_none() && ds.text_edit.is_none()) } diff --git a/crates/engine/src/vector_fill_rule_tests.rs b/crates/engine/src/vector_fill_rule_tests.rs new file mode 100644 index 00000000..8a8fe850 --- /dev/null +++ b/crates/engine/src/vector_fill_rule_tests.rs @@ -0,0 +1,306 @@ +//! Fill rules on vectors: `vector.fillRule` sets the rule a filled area fills by and +//! `vector.reverseLoop` turns one of its loops round — which is how a hole is made and unmade. +//! The numbers come from the two rules' definitions: non-zero fills where the winding count is not +//! zero (so a hole is a loop running against its outer loop), even-odd where it is odd (so the +//! direction of a loop doesn't matter). + +use figcraft_doc::{NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule}; +use figcraft_geom::{BezPath, Point}; +use serde_json::{Value, json}; + +use crate::Session; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn net(s: &Session, id: NodeId) -> VectorNetwork { + match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Vector { network } => network.clone(), + other => panic!("not a vector: {other:?}"), + } +} + +/// A 100 × 100 square with a 25…75 hole, both loops in one area: the outer one clockwise, the inner +/// one the same way round (so non-zero fills the hole too until it is turned round). +fn ring() -> VectorNetwork { + let mut path = BezPath::new(); + for (x0, y0, x1, y1) in [(0.0, 0.0, 100.0, 100.0), (25.0, 25.0, 75.0, 75.0)] { + path.move_to((x0, y0)); + path.line_to((x1, y0)); + path.line_to((x1, y1)); + path.line_to((x0, y1)); + path.close_path(); + } + let mut net = VectorNetwork::from_path(&path); + net.regions[0].winding = WindingRule::NonZero; + net +} + +/// A selected vector layer holding `network`. +fn vector_layer(s: &mut Session, network: VectorNetwork) -> NodeId { + let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let id = NodeId(id); + ex(s, "vector.edit.begin", json!({"id": id})); + ex(s, "vector.edit.end", json!({})); + { + let doc = s.doc_mut().unwrap(); + match &mut doc.get_mut(id).unwrap().kind { + NodeKind::Vector { network: net } => *net = network, + other => panic!("not a vector: {other:?}"), + } + } + ex(s, "select.set", json!({"ids": [id]})); + id +} + +/// A selected vector layer holding `ring()`. +fn ringed(s: &mut Session) -> NodeId { + vector_layer(s, ring()) +} + +/// Two 50 × 100 squares side by side share the vertical edge between them: one segment listed by +/// both areas' single loops. A segment shared between areas is the case `vector.reverseLoop` must +/// not flip, or a neighbour's fill or stroke would turn round with it. +fn two_squares() -> VectorNetwork { + // 0:A(0,0) 1:B(50,0) 2:C(100,0) 3:D(100,100) 4:E(50,100) 5:F(0,100) + // s0 A→B (left bottom), s1 B→E (the shared vertical edge), s2 E→F (left top), + // s3 F→A (left left), s4 B→C (right bottom), s5 C→D (right right), s6 D→E (right top); + // the right area's left edge is s1 walked the other way, so the squares share one segment. + // region 0: left square A→B→E→F→A = [s0, s1, s2, s3]. + // region 1: right square B→C→D→E→B = [s4, s5, s6, s1] (s1 walked backwards). + VectorNetwork { + vertices: vec![ + Vertex::new(0.0, 0.0), + Vertex::new(50.0, 0.0), + Vertex::new(100.0, 0.0), + Vertex::new(100.0, 100.0), + Vertex::new(50.0, 100.0), + Vertex::new(0.0, 100.0), + ], + segments: vec![ + Segment::line(0, 1), + Segment::line(1, 4), + Segment::line(4, 5), + Segment::line(5, 0), + Segment::line(1, 2), + Segment::line(2, 3), + Segment::line(3, 4), + ], + regions: vec![ + Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero }, + Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero }, + ], + } +} + +/// A selected vector layer holding [`two_squares`]. +fn squared(s: &mut Session) -> NodeId { + vector_layer(s, two_squares()) +} + +/// Two areas of two segments each over the same line: one curve above it and one below. Both areas +/// list the line's segment — the first area first (`[line, above]`), the second area last +/// (`[below, line]`) — and a two-segment ring reads the same whichever way its list is turned, so +/// these only turn round when a segment is stored the other way. +fn two_lenses() -> VectorNetwork { + // 0:A(0,0) 1:B(100,0) + // s0 A→B the straight line both areas use; s1 B→A curving above it; s2 A→B curving below it. + VectorNetwork { + vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)], + segments: vec![ + Segment::line(0, 1), + Segment { a: 1, b: 0, ta: (0.0, -50.0), tb: (0.0, -50.0) }, + Segment { a: 0, b: 1, ta: (0.0, 50.0), tb: (0.0, 50.0) }, + ], + regions: vec![ + Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }, + Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero }, + ], + } +} + +/// A selected vector layer holding [`two_lenses`]. +fn lensed(s: &mut Session) -> NodeId { + vector_layer(s, two_lenses()) +} + +/// Two areas whose only loop is the same closed segment (a curve the path closes with a straight +/// line): a one-segment loop's direction is its segment's stored ends, and both areas read them. +fn one_shared_segment() -> VectorNetwork { + VectorNetwork { + vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)], + segments: vec![Segment { a: 0, b: 1, ta: (0.0, -60.0), tb: (0.0, -60.0) }], + regions: vec![Region { loops: vec![vec![0]], winding: WindingRule::NonZero }, Region { loops: vec![vec![0]], winding: WindingRule::NonZero }], + } +} + +/// The point in the middle of the hole, and one on the square's left band. +const HOLE: Point = Point::new(50.0, 50.0); +const BAND: Point = Point::new(10.0, 50.0); + +#[test] +fn a_loop_running_with_its_outer_loop_fills_the_hole_until_it_is_turned_round() { + let mut s = Session::new(); + let id = ringed(&mut s); + let n = net(&s, id); + assert_eq!(n.regions.len(), 1); + assert_eq!(n.regions[0].loops.len(), 2, "an outer loop and the hole's"); + assert!(n.region_contains(0, BAND), "the band fills"); + assert!(n.region_contains(0, HOLE), "the hole fills while its loop runs the same way"); + // Turning the inner loop round makes it a hole under non-zero. + let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 1})); + assert_eq!(r["reversed"], json!(true)); + let n = net(&s, id); + assert!(n.region_contains(0, BAND), "the band still fills"); + assert!(!n.region_contains(0, HOLE), "the hole is empty"); + assert_eq!(n.region_winding(0, HOLE), Some(0), "its loops cancel out there"); + // Undo turns it back. + s.undo().unwrap(); + assert!(net(&s, id).region_contains(0, HOLE), "one undo step restores the loop"); +} + +#[test] +fn the_rule_switches_every_area_or_only_the_one_asked_for() { + let mut s = Session::new(); + let id = ringed(&mut s); + // Even-odd leaves the hole empty whatever way its loop runs. + let r = ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"})); + assert_eq!((r["regions"].clone(), r["rule"].clone()), (json!(1), json!("evenOdd"))); + let n = net(&s, id); + assert_eq!(n.regions[0].winding, WindingRule::EvenOdd); + assert!(!n.region_contains(0, HOLE), "even-odd counts the hole's loop as a second crossing"); + // And back. + ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "nonZero"})); + assert!(net(&s, id).region_contains(0, HOLE), "non-zero fills it again"); + // One area of several, by index. + let n = net(&s, id); + let mut two = n.clone(); + two.regions.push(n.regions[0].clone()); + { + let doc = s.doc_mut().unwrap(); + match &mut doc.get_mut(id).unwrap().kind { + NodeKind::Vector { network } => *network = two, + other => panic!("not a vector: {other:?}"), + } + } + ex(&mut s, "vector.fillRule", json!({"id": id, "region": 1, "rule": "evenOdd"})); + let n = net(&s, id); + assert_eq!(n.regions[0].winding, WindingRule::NonZero, "the other area keeps its rule"); + assert_eq!(n.regions[1].winding, WindingRule::EvenOdd); + // Every area at once. + ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"})); + assert!(net(&s, id).regions.iter().all(|r| r.winding == WindingRule::EvenOdd)); +} + +#[test] +fn junk_parameters_are_refused_and_change_nothing() { + let mut s = Session::new(); + let id = ringed(&mut s); + let before = net(&s, id); + for p in [ + json!({"id": id, "rule": "nope"}), + json!({"id": id}), + json!({"id": id, "rule": 7}), + json!({"id": id, "rule": "evenOdd", "region": -1}), + json!({"id": id, "rule": "evenOdd", "region": 1.5}), + json!({"id": id, "rule": "evenOdd", "region": "x"}), + json!({"id": id, "rule": "evenOdd", "region": 99}), + ] { + assert!(s.execute("vector.fillRule", &p).is_err(), "{p} is refused"); + } + for p in [ + json!({"id": id}), + json!({"id": id, "region": 0}), + json!({"id": id, "region": 0, "loop": 99}), + json!({"id": id, "region": 99, "loop": 0}), + json!({"id": id, "region": -2, "loop": 0}), + ] { + assert!(s.execute("vector.reverseLoop", &p).is_err(), "{p} is refused"); + } + assert_eq!(net(&s, id), before, "nothing changed"); + // A rectangle that was never a vector is refused too. + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + assert!(s.execute("vector.fillRule", &json!({"id": rect, "rule": "evenOdd"})).is_err()); + assert!(s.execute("vector.reverseLoop", &json!({"id": rect, "region": 0, "loop": 0})).is_err()); +} + +#[test] +fn reversing_a_loop_shared_with_a_neighbour_leaves_its_segment_and_fill_alone() { + let mut s = Session::new(); + let id = squared(&mut s); + let n = net(&s, id); + assert_eq!(n.regions.len(), 2, "two areas, side by side"); + // Both areas fill under non-zero: a point in each. + assert!(n.region_contains(0, Point::new(25.0, 50.0)), "the left area fills"); + assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the right area fills"); + // The vertical edge between them is one segment listed by both areas' loops. + const SHARED: usize = 1; + assert!(n.regions[0].loops[0].contains(&SHARED) && n.regions[1].loops[0].contains(&SHARED), "one segment is shared"); + let before = n.segments[SHARED]; + let left_winding = n.region_winding(0, Point::new(25.0, 50.0)).unwrap(); + assert_ne!(left_winding, 0, "the left area winds once"); + // Reverse the left area's only loop. + let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0})); + assert_eq!(r["reversed"], json!(true)); + let n = net(&s, id); + // The shared segment's stored ends are untouched: reversing the loop must not flip a neighbour's + // segment (reversing the list already turns this loop's traversal of it the other way). + assert_eq!(n.segments[SHARED], before, "the shared segment is left as it was"); + // The neighbour's fill is unchanged: it still fills its interior. + assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the neighbour still fills"); + // The reversed loop really turned round: its winding at a left-area point flipped sign. + assert_eq!(n.region_winding(0, Point::new(25.0, 50.0)), Some(-left_winding), "the left loop reversed"); + // Undo restores the shared segment and the loop. + s.undo().unwrap(); + assert_eq!(net(&s, id).segments[SHARED], before, "undo restores the shared segment too"); +} + +#[test] +fn a_two_segment_loop_turns_round_with_its_shared_segment_listed_first_or_last() { + let mut s = Session::new(); + let id = lensed(&mut s); + const SHARED: usize = 0; + const ABOVE: Point = Point::new(50.0, -25.0); + const BELOW: Point = Point::new(50.0, 25.0); + let n = net(&s, id); + assert_eq!(n.regions.len(), 2, "two areas over the same line"); + assert_eq!((n.regions[0].loops[0][0], n.regions[0].loops[0].len()), (SHARED, 2), "the shared segment is listed first"); + assert_eq!((n.regions[1].loops[0][1], n.regions[1].loops[0].len()), (SHARED, 2), "and last"); + assert!(n.region_contains(0, ABOVE), "the area above the line fills"); + assert!(n.region_contains(1, BELOW), "the area below it fills"); + let (above, below) = (n.region_winding(0, ABOVE).unwrap(), n.region_winding(1, BELOW).unwrap()); + assert_ne!(above, 0, "the area above winds once"); + assert_ne!(below, 0, "the area below winds once"); + let shared = n.segments[SHARED]; + // Turn the area that lists the shared segment first round: a two-segment ring reads the same + // either way, so this only works by storing one of its segments the other way. + assert_eq!(ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0}))["reversed"], json!(true)); + let n = net(&s, id); + assert_eq!(n.segments[SHARED], shared, "the shared segment keeps its ends"); + assert!(n.region_contains(0, ABOVE), "the area still fills"); + assert_eq!(n.region_winding(0, ABOVE), Some(-above), "it really turned round"); + assert_eq!(n.region_winding(1, BELOW), Some(below), "the neighbour did not"); + // And the area that lists it last. + ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 1, "loop": 0})); + let n = net(&s, id); + assert_eq!(n.segments[SHARED], shared, "still untouched"); + assert_eq!(n.region_winding(1, BELOW), Some(-below), "the second area really turned round"); + assert_eq!(n.region_winding(0, ABOVE), Some(-above), "the first stays as it was"); + // Undo turns each back. + s.undo().unwrap(); + assert_eq!(net(&s, id).region_winding(1, BELOW), Some(below), "undo turns the second back"); + s.undo().unwrap(); + assert_eq!(net(&s, id).region_winding(0, ABOVE), Some(above), "and the first"); +} + +#[test] +fn a_one_segment_loop_shared_with_another_is_refused_instead_of_claiming_a_turn() { + let mut s = Session::new(); + let id = vector_layer(&mut s, one_shared_segment()); + let before = net(&s, id); + let err = s.execute("vector.reverseLoop", &json!({"id": id, "region": 0, "loop": 0})).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("shares its segments"), "the error says why: {message}"); + assert_eq!(net(&s, id), before, "nothing changed"); +} diff --git a/crates/engine/src/vector_multi_behaviour_tests.rs b/crates/engine/src/vector_multi_behaviour_tests.rs index 1d2075b1..b1e6a948 100644 --- a/crates/engine/src/vector_multi_behaviour_tests.rs +++ b/crates/engine/src/vector_multi_behaviour_tests.rs @@ -169,13 +169,19 @@ fn editing_several_vectors_at_once() { assert!(near(page_pos(&s, a, atl), (1.0, 0.0)) && near(page_pos(&s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer"); s.undo().unwrap(); assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "one undo step for every layer"); - // Dragging one selected point drags them all. - let active = editing(&s).unwrap().id; - let grab = if active == a { (0.0, 0.0) } else { (200.0, 0.0) }; - drag(&mut s, grab, (grab.0 + 10.0, grab.1 + 20.0), NONE); - assert!(near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)), "dragging one selected point moves them all"); + // Dragging one selected point drags them all (one on the transform box's edge: its corners + // scale, as with points of one layer). + let atr = point_at(&s, a, (100.0, 0.0)); + ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true})); + drag(&mut s, (100.0, 0.0), (110.0, 20.0), NONE); + assert!( + near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, a, atr), (110.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)), + "dragging one selected point moves them all" + ); s.undo().unwrap(); assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "the drag is one undo step"); + ex(&mut s, "vector.edit.select", json!({"vertices": [atl]})); + ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true})); // Corner radius, mirroring and deleting reach every layer's selected points. ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 10})); assert!(net(&s, a).vertices[atl].radius == 10.0 && net(&s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer"); diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index a3fbf803..3b79dd4b 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -4,7 +4,7 @@ //! Entering edit mode on a non-vector shape converts it to a vector first (like the incumbent). //! Vertex coordinates are node-local; after each edit the node is re-fitted so its box wraps the //! network again. The network edits themselves live in [`ops`]; pointer gestures in `pointer.rs`, -//! the transform box around several points in [`transform`], point snapping in `point_snap.rs` and +//! the transform box around several points (in one layer or several) in [`transform`], point snapping in `point_snap.rs` and //! hovering, hatching and selecting filled regions in [`regions`]. pub mod ops; diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 6667a209..4721d6bd 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -262,7 +262,7 @@ impl Session { } /// Every edited layer's selected points (segment ends included) other than the active one's. - fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec)> { + pub(super) fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec)> { ve.others .iter() .filter_map(|o| { @@ -310,7 +310,7 @@ impl Session { let Some(b) = transform::shown_box(&doc, ve) else { return Ok(()) }; match bh { BoxHit::Inside => { - let g = self.points_gesture(ve.id, ev.p(), b.points, vec![], None, true)?; + let g = self.points_gesture(ve.id, ev.p(), b.points, b.others, None, true)?; self.begin_interaction("Move points")?; self.vgesture(g); } @@ -320,7 +320,7 @@ impl Session { self.vgesture(VGesture::BoxRotate { b, start_angle, angle: 0.0 }); } BoxHit::Handle(handle) => { - let targets = Arc::new(point_snap::targets(&doc, page, Some(ve.id), &b.points, &[])); + let targets = Arc::new(point_snap::targets_moving(&doc, page, Some(ve.id), &b.points, &[], &b.others)); self.begin_interaction("Scale points")?; self.vgesture(VGesture::BoxScale { b, handle, targets, pick }); } @@ -428,11 +428,11 @@ impl Session { } /// The region of the active layer a press at `ev` would take (Move tool, `h` the network's part - /// there). Inside the transform box around points selected in their own right, the box keeps - /// its drag unless the region is already selected. + /// there). Inside the transform box around points selected in their own right (in any edited + /// layer), the box keeps its drag unless the region is already selected. fn region_under(doc: &Document, ve: &VectorEdit, h: Option, ev: &PointerEvent) -> Option { let r = region_hit(doc, ve, ev.p(), ev.zoom)?; - let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty(); + let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty() || ve.others_selected(); let in_box = !ev.shift && own && transform::box_hit(doc, ve, h, ev.p(), ev.zoom) == Some(BoxHit::Inside); (!in_box || ve.regions.contains(&r)).then_some(r) } @@ -883,7 +883,7 @@ impl Session { let q = b.frame.inverse() * (ev.p() + c); let (x0, y0, x1, y1) = crate::tools::resized_box(handle, q, b.width, b.height, ev.shift, ev.alt); let m = b.onto(x0, y0, x1, y1); - self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?; + self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?; } VGesture::BoxRotate { b, start_angle, .. } => { let c = b.center(); @@ -892,7 +892,7 @@ impl Session { angle = (angle / 15.0).round() * 15.0; } let m = figcraft_geom::rotate_about(Affine::IDENTITY, c, -angle); - self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?; + self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?; self.tool.overlay.cursor = Cursor::Rotate((ev.p() - c).atan2().to_degrees()); self.vgesture(VGesture::BoxRotate { b, start_angle, angle }); } @@ -1007,10 +1007,7 @@ impl Session { } else { self.cancel_interaction()?; if click_clears { - set_state(self, |e| { - e.vertices.clear(); - e.segments.clear(); - })?; + self.clear_points()?; } } } @@ -1037,23 +1034,7 @@ impl Session { } })?, VGesture::Erase { .. } | VGesture::Paint { .. } => self.commit_interaction()?, - VGesture::BoxScale { pick, .. } => { - let ds = self.ds()?; - let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc)); - match pick.filter(|_| clicked) { - Some(v) => { - self.cancel_interaction()?; - set_state(self, |e| { - if let Some(i) = e.vertices.iter().position(|x| *x == v) { - e.vertices.remove(i); - } else { - e.vertices.push(v); - } - })?; - } - None => self.commit_interaction()?, - } - } + VGesture::BoxScale { pick, .. } => self.box_scale_up(pick)?, VGesture::Cut { start, hit } => self.cut_up(ve, start, hit, ev)?, VGesture::Width(g) => self.width_up(g)?, } @@ -1063,6 +1044,34 @@ impl Session { Ok(()) } + /// Clears the point selection of the active layer and of the other layers being edited. + fn clear_points(&mut self) -> Result<()> { + set_state(self, |e| { + e.vertices.clear(); + e.segments.clear(); + for o in &mut e.others { + o.vertices.clear(); + o.segments.clear(); + } + }) + } + + /// The transform box's corner or edge was let go: a Shift-click (no drag) on the corner's + /// point `pick` toggles it in the selection; anything else keeps the scale. + fn box_scale_up(&mut self, pick: Option) -> Result<()> { + let ds = self.ds()?; + let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc)); + let Some(v) = pick.filter(|_| clicked) else { return self.commit_interaction() }; + self.cancel_interaction()?; + set_state(self, |e| { + if let Some(i) = e.vertices.iter().position(|x| *x == v) { + e.vertices.remove(i); + } else { + e.vertices.push(v); + } + }) + } + /// Point `v` of vector `id`, dragged and dropped within `tol` (page units) of another of its /// points, merges into that point (in the same undo step as the move), which stays selected. fn merge_dropped_point(&mut self, id: NodeId, v: usize, tol: f64) -> Result<()> { diff --git a/crates/engine/src/vectoredit/transform.rs b/crates/engine/src/vectoredit/transform.rs index 1d5f27a6..ca6e59e2 100644 --- a/crates/engine/src/vectoredit/transform.rs +++ b/crates/engine/src/vectoredit/transform.rs @@ -1,11 +1,11 @@ //! The transform box of vector edit mode. With two or more points selected (Move tool) a box in the -//! vector's own axes wraps them: its corners and edges scale them (Shift keeps proportions, Alt -//! scales from the centre), dragging just outside a corner rotates them about its centre (Shift: -//! 15° steps) and dragging inside it moves them; each drag is one undo step (the gestures live in -//! `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP, +//! vector's own axes wraps them (in the page's axes when they are in several layers): its corners +//! and edges scale them (Shift keeps proportions, Alt scales from the centre), dragging just +//! outside a corner rotates them about its centre (Shift: 15° steps) and dragging inside it moves +//! them; each drag is one undo step for every layer (the gestures live in `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP, //! and while points are selected the flip and 90°/180° rotation commands act on them. -use figcraft_doc::Document; +use figcraft_doc::{Document, NodeId}; use figcraft_geom::{Affine, Point}; use serde_json::{Value, json}; @@ -19,16 +19,23 @@ use crate::{Result, Session, bad, spec}; const EPS: f64 = 1e-9; /// The selected points' box: the page transform of its top-left corner (axes along the vector's -/// own), its size in those axes and the points in it. +/// own), its size in those axes and the points in it: the active layer's, and other edited +/// layers' in `others`. #[derive(Clone, Debug, PartialEq)] pub struct PointBox { pub frame: Affine, pub width: f64, pub height: f64, pub points: Vec, + pub others: Vec<(NodeId, Vec)>, } impl PointBox { + /// How many points the box wraps, in every layer. + pub fn point_count(&self) -> usize { + self.points.len() + self.others.iter().map(|(_, v)| v.len()).sum::() + } + /// Corners in page coordinates (tl, tr, br, bl). pub fn corners(&self) -> [Point; 4] { let (f, w, h) = (self.frame, self.width, self.height); @@ -62,17 +69,37 @@ pub fn point_box(doc: &Document, ve: &VectorEdit) -> Option { if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) { return None; } - Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points }) + Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points, others: vec![] }) +} + +/// The box of the selected points of every edited layer, in the page's axes (the layers' own may +/// differ). +fn layers_box(doc: &Document, ve: &VectorEdit) -> Option { + let points = selected_points(network(doc, ve.id)?, ve); + let others = Session::others_moving(doc, ve); + let page = |id: NodeId, verts: &[usize]| -> Vec { + let w = doc.world_transform(id); + network(doc, id).map(|n| verts.iter().filter_map(|v| n.vertices.get(*v)).map(|v| w * v.point()).collect()).unwrap_or_default() + }; + let all = page(ve.id, &points).into_iter().chain(others.iter().flat_map(|(id, v)| page(*id, v))); + let (x0, y0, x1, y1) = all.fold((f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |(a, b, c, d), p| { + (a.min(p.x), b.min(p.y), c.max(p.x), d.max(p.y)) + }); + let (width, height) = (x1 - x0, y1 - y0); + if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) { + return None; + } + Some(PointBox { frame: Affine::translate((x0, y0)), width, height, points, others }) } /// The box drawn and dragged on the canvas: the Move tool, no open pen path, and two or more -/// points that aren't all in one place, all in the active layer. -/// shortcut: with points selected in several layers there is no box (drags, nudges and the Design panel still reach them all); wrap them all if asked for. +/// points that aren't all in one place, in any of the edited layers. pub fn shown_box(doc: &Document, ve: &VectorEdit) -> Option { - if ve.tool != VTool::Move || ve.anchor.is_some() || ve.others_selected() { + if ve.tool != VTool::Move || ve.anchor.is_some() { return None; } - point_box(doc, ve).filter(|b| b.points.len() >= 2 && (b.width > EPS || b.height > EPS)) + let b = if ve.others_selected() { layers_box(doc, ve) } else { point_box(doc, ve) }; + b.filter(|b| b.point_count() >= 2 && (b.width > EPS || b.height > EPS)) } /// What the transform box does under the pointer. @@ -115,12 +142,21 @@ pub fn box_hit(doc: &Document, ve: &VectorEdit, h: Option, p: Point, zoom: /// Map points `verts` of vector `id` (and the handles at them) through page transform `m`, then /// re-fit the vector. -pub fn transform_points(doc: &mut Document, id: figcraft_doc::NodeId, verts: &[usize], m: Affine) -> Result<()> { +pub fn transform_points(doc: &mut Document, id: NodeId, verts: &[usize], m: Affine) -> Result<()> { let w = doc.world_transform(id); let local = w.inverse() * m * w; edit(doc, id, |net| ops::transform_vertices(net, verts, local)) } +/// Map the points of box `b` (`id`: the active layer) in every layer through page transform `m`. +pub fn transform_box(doc: &mut Document, id: NodeId, b: &PointBox, m: Affine) -> Result<()> { + transform_points(doc, id, &b.points, m)?; + for (o, verts) in &b.others { + transform_points(doc, *o, verts, m)?; + } + Ok(()) +} + /// Transform the selected points by `f(box)` (page space) when editing with points selected and /// the command names no layers (`p` has no `ids` or `id`); `None` otherwise (it then acts on /// layers). diff --git a/crates/engine/src/vectoredit/transform_tests.rs b/crates/engine/src/vectoredit/transform_tests.rs index d69baf52..f29ed2a5 100644 --- a/crates/engine/src/vectoredit/transform_tests.rs +++ b/crates/engine/src/vectoredit/transform_tests.rs @@ -307,3 +307,122 @@ fn edit_mode_transform_commands_survive_junk() { } } } + +/// Two edited layers turned differently: `a` a 100×100 square at the origin turned -20°, `b` one +/// at (200, 0) turned 30° (the active layer), with two of `a`'s corners and all of `b`'s points +/// selected. Neither layer's axes are the page's. Snapping (the pixel grid too) is off so drags +/// land exactly. +fn two_layers(s: &mut Session) -> (NodeId, NodeId) { + let mut square = |x: f64| crate::params::id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap(); + let (a, b) = (square(0.0), square(200.0)); + ex(s, "node.set", json!({"id": a, "props": {"rotation": -20}})); + ex(s, "node.set", json!({"id": b, "props": {"rotation": 30}})); + ex(s, "view.snapGeometry", json!({"on": false})); + ex(s, "view.snapObjects", json!({"on": false})); + ex(s, "view.snapPixel", json!({"on": false})); + ex(s, "vector.edit.begin", json!({"ids": [a, b]})); + let n = net(s, a); + ex(s, "vector.edit.select", json!({"vertices": [vertex_at(&n, 0.0, 0.0), vertex_at(&n, 100.0, 100.0)]})); + ex(s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3], "add": true})); + assert_eq!(ve(s).id, b, "the layer named last is the active one"); + (a, b) +} + +/// Every selected point's page position, layer by layer. +fn selected_page_points(s: &Session, a: NodeId, b: NodeId) -> Vec<(NodeId, usize, Point)> { + let e = ve(s); + let parts = e.parts(); + [a, b] + .iter() + .flat_map(|id| { + let part = parts.iter().find(|p| p.id == *id).unwrap(); + part.vertices.iter().map(|v| (*id, *v, page_pt(s, *id, *v))).collect::>() + }) + .collect() +} + +#[test] +fn the_box_wraps_points_selected_in_several_layers() { + let mut s = Session::new(); + let (a, b) = two_layers(&mut s); + let before = selected_page_points(&s, a, b); + assert_eq!(before.len(), 6); + // On the page's axes: `b` is turned, so neither layer's own axes fit both. + let (x0, y0) = before.iter().fold((f64::INFINITY, f64::INFINITY), |(x, y), (_, _, p)| (x.min(p.x), y.min(p.y))); + let (x1, y1) = before.iter().fold((f64::NEG_INFINITY, f64::NEG_INFINITY), |(x, y), (_, _, p)| (x.max(p.x), y.max(p.y))); + let c = s.vector_edit_box().expect("a box around the points of both layers"); + assert!(near(c[0], pt(x0, y0)) && near(c[2], pt(x1, y1)), "{c:?} vs ({x0}, {y0})–({x1}, {y1})"); + + // The bottom-right corner dragged to twice the width scales every point in both layers. + drag(&mut s, (x1, y1), (x0 + 2.0 * (x1 - x0), y1)); + for (id, v, p) in &before { + let q = page_pt(&s, *id, *v); + assert!(near(q, pt(x0 + 2.0 * (p.x - x0), p.y)), "point {v} of {id:?}: {p:?} → {q:?}"); + } + s.undo().unwrap(); + for (id, v, p) in &before { + assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}"); + } + + // From just outside the top-right corner, a quarter turn clockwise about the box's centre. + let ctr = pt((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let from = pt(x1 + 10.0, y0 - 10.0); + let d = from - ctr; + drag(&mut s, (from.x, from.y), (ctr.x - d.y, ctr.y + d.x)); + for (id, v, p) in &before { + let r = *p - ctr; + let q = page_pt(&s, *id, *v); + assert!(near(q, pt(ctr.x - r.y, ctr.y + r.x)), "point {v} of {id:?}: {p:?} → {q:?}"); + } + s.undo().unwrap(); + for (id, v, p) in &before { + assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}"); + } +} + +#[test] +fn inside_the_box_of_several_layers_drags_and_a_click_clears_them_all() { + let mut s = Session::new(); + let (a, b) = two_layers(&mut s); + let before = selected_page_points(&s, a, b); + // Clear of both networks: between the two squares, inside the box. + let doc = s.doc().unwrap().clone(); + assert!(hit_any(&doc, &ve(&s), pt(150.0, 50.0), 6.0).is_none()); + assert_eq!(box_hit(&doc, &ve(&s), None, pt(150.0, 50.0), 1.0), Some(BoxHit::Inside)); + drag(&mut s, (150.0, 50.0), (170.0, 60.0)); + for (id, v, p) in &before { + assert!(near(page_pt(&s, *id, *v), *p + Vec2::new(20.0, 10.0)), "point {v} of {id:?} moves along"); + } + s.undo().unwrap(); + click(&mut s, 150.0, 50.0); + assert!(ve(&s).parts().iter().all(|p| p.vertices.is_empty()), "a click inside clears every layer's points"); +} + +#[test] +fn inside_the_box_of_another_layer_drags_over_a_region_of_the_active_one() { + // `b`'s points selected, `a` (a filled 300 square around `b`) active with nothing selected: + // the press at the box's centre lands on `a`'s region too. + let mut s = Session::new(); + let mut square = |x: f64, size: f64| { + crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": x, "y": x, "width": size, "height": size}))["id"]).unwrap() + }; + let (a, b) = (square(0.0, 300.0), square(100.0, 50.0)); + ex(&mut s, "view.snapGeometry", json!({"on": false})); + ex(&mut s, "view.snapObjects", json!({"on": false})); + ex(&mut s, "view.snapPixel", json!({"on": false})); + ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]})); + ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3]})); + ex(&mut s, "vector.edit.select", json!({"id": a, "add": true})); + let doc = s.doc().unwrap().clone(); + let e = ve(&s); + assert!(e.id == a && e.vertices.is_empty() && e.others_selected()); + assert!(hit_any(&doc, &e, pt(125.0, 125.0), 6.0).is_none()); + assert!(regions::region_hit(&doc, &e, pt(125.0, 125.0), 1.0).is_some(), "a region of the active layer under the press"); + assert_eq!(box_hit(&doc, &e, None, pt(125.0, 125.0), 1.0), Some(BoxHit::Inside)); + let before: Vec = (0..4).map(|v| page_pt(&s, b, v)).collect(); + drag(&mut s, (125.0, 125.0), (145.0, 135.0)); + assert!(ve(&s).regions.is_empty(), "the region isn't selected"); + for (v, p) in before.iter().enumerate() { + assert!(near(page_pt(&s, b, v), *p + Vec2::new(20.0, 10.0)), "point {v} of b moves along"); + } +} diff --git a/crates/engine/tests/empty_text_box_undo.rs b/crates/engine/tests/it/empty_text_box_undo.rs similarity index 96% rename from crates/engine/tests/empty_text_box_undo.rs rename to crates/engine/tests/it/empty_text_box_undo.rs index ec73b83a..0a302ac2 100644 --- a/crates/engine/tests/empty_text_box_undo.rs +++ b/crates/engine/tests/it/empty_text_box_undo.rs @@ -1,5 +1,3 @@ -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - use figcraft_engine::{ Session, doc::NodeKind, diff --git a/crates/engine/tests/library_style_acceptance.rs b/crates/engine/tests/it/library_style_acceptance.rs similarity index 97% rename from crates/engine/tests/library_style_acceptance.rs rename to crates/engine/tests/it/library_style_acceptance.rs index 233ddc8b..07940132 100644 --- a/crates/engine/tests/library_style_acceptance.rs +++ b/crates/engine/tests/it/library_style_acceptance.rs @@ -1,5 +1,4 @@ //! Accepting a library update that changes only a paint style refreshes the layers linked to it. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)] use figcraft_doc::{NodeId, StyleKind}; use figcraft_engine::Session; diff --git a/crates/engine/tests/list_indent_response.rs b/crates/engine/tests/it/list_indent_response.rs similarity index 97% rename from crates/engine/tests/list_indent_response.rs rename to crates/engine/tests/it/list_indent_response.rs index 1dae8754..e6d47956 100644 --- a/crates/engine/tests/list_indent_response.rs +++ b/crates/engine/tests/it/list_indent_response.rs @@ -1,5 +1,4 @@ //! `text.listIndent` / `text.listOutdent` report `changed` only when the document changed. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use figcraft_engine::{ Session, diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 00000000..a97e3434 --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,14 @@ +//! The crate's integration tests, built as one test binary. + +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod empty_text_box_undo; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)] +mod library_style_acceptance; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod list_indent_response; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod scope_reset; +#[allow(clippy::unwrap_used)] +mod selection_colors; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod shape_builder_response; diff --git a/crates/engine/tests/scope_reset.rs b/crates/engine/tests/it/scope_reset.rs similarity index 95% rename from crates/engine/tests/scope_reset.rs rename to crates/engine/tests/it/scope_reset.rs index 6d875d12..1838e8a9 100644 --- a/crates/engine/tests/scope_reset.rs +++ b/crates/engine/tests/it/scope_reset.rs @@ -1,5 +1,3 @@ -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - use figcraft_engine::Session; use serde_json::json; diff --git a/crates/engine/tests/selection_colors.rs b/crates/engine/tests/it/selection_colors.rs similarity index 99% rename from crates/engine/tests/selection_colors.rs rename to crates/engine/tests/it/selection_colors.rs index e13f9f59..4e559d27 100644 --- a/crates/engine/tests/selection_colors.rs +++ b/crates/engine/tests/it/selection_colors.rs @@ -1,5 +1,3 @@ -#![allow(clippy::unwrap_used)] - use figcraft_color::Paint; use figcraft_doc::NodeId; use figcraft_engine::{ diff --git a/crates/engine/tests/shape_builder_response.rs b/crates/engine/tests/it/shape_builder_response.rs similarity index 95% rename from crates/engine/tests/shape_builder_response.rs rename to crates/engine/tests/it/shape_builder_response.rs index e1426823..6187e556 100644 --- a/crates/engine/tests/shape_builder_response.rs +++ b/crates/engine/tests/it/shape_builder_response.rs @@ -1,4 +1,3 @@ -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use figcraft_engine::Session; use serde_json::{Value, json}; diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index 4777be18..a271a908 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -104,7 +104,10 @@ fn themed() -> Document { add(&mut doc, frame, NodeKind::Rectangle, "Swatch", (10.0, 10.0), (80.0, 60.0), &|n| { let mut g = Gradient::default_for(GradientKind::Linear, Color::BLACK); g.stops = vec![ColorStop { position: 0.0, color: Color::BLACK }, ColorStop { position: 1.0, color: Color::WHITE }]; - n.fills = vec![solid(0.5, 0.5, 0.5), Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal }]; + n.fills = vec![ + solid(0.5, 0.5, 0.5), + Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }, + ]; n.strokes = vec![solid(0.0, 0.0, 0.0), solid(1.0, 0.0, 0.0)]; n.effects = vec![Effect { kind: EffectKind::DropShadow(Shadow { diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 0cad2122..234e1459 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -25,10 +25,10 @@ use figcraft_doc::proto::{ Action, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, }; use figcraft_doc::{ - AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, HConstraint, LayoutMode, LetterSpacing, - LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StyleKind, - TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, VariableValue, VectorNetwork, - WindingRule, + AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode, + LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, + StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, + VariableValue, VectorNetwork, WindingRule, }; use serde_json::Value; @@ -168,7 +168,12 @@ message PrototypeStartingPoint string name=1, string position=3 message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2 message GUIDPositionMapEntry GUID id=1, string position=2 message GUIDPositionMap GUIDPositionMapEntry[] entries=1 -message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 +enum GridTrackSizingType FLEX=0 FIXED=1 HUG=2 UNKNOWN=3 +message GridTrackSizeFunction GridTrackSizingType type=1, float value=2 +message GridTrackSize GridTrackSizeFunction minSizing=1, GridTrackSizeFunction maxSizing=2 +message GridTrackSizeEntry GUID id=1, GridTrackSize trackSize=2 +message GridTrackSizeMap GridTrackSizeEntry[] entries=1 +message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GridTrackSizeMap gridColumnsSizing=443, GridTrackSizeMap gridRowsSizing=444, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 struct Blob byte[] bytes message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6 "; @@ -277,6 +282,37 @@ fn s(x: &str) -> Kv<'static> { fn b(x: bool) -> Kv<'static> { Kv::Bool(x) } + +/// A track's size as the format's enum name and value, when both sizing functions agree (the +/// importer keeps only those). `None`: the format has no name for it, so an equal track stands in. +fn grid_track_size(track: &GridTrack) -> Option<(&'static str, f64)> { + let value = track.value; + let fits = value.is_finite() && (0.0..=crate::fig::map::MAX_GRID_TRACK_VALUE).contains(&value); + match track.sizing { + Sizing::Fixed if fits => Some(("FIXED", value)), + Sizing::Fill if fits && value > 0.0 => Some(("FLEX", value)), + Sizing::Hug => Some(("HUG", 0.0)), + _ => None, + } +} + +/// A grid axis's track sizes, keyed by track id like the order map. A track the document doesn't +/// set is left out, and one the format can't hold keeps its id (the import equalises it) and sets +/// `dropped`. +fn grid_sizing(tracks: &[GridTrack], ids: &[u32], dropped: &mut bool) -> Option> { + let mut entries = Vec::new(); + for (track, id) in tracks.iter().zip(ids) { + match grid_track_size(track) { + Some((kind, value)) => { + let size = || o(vec![("type", e(kind)), ("value", fl(value))]); + entries.push(o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", size()), ("maxSizing", size())]))])); + } + None => *dropped = true, + } + } + (!entries.is_empty()).then(|| o(vec![("entries", arr(entries))])) +} + fn guid(session: u32, local: u32) -> Kv<'static> { o(vec![("sessionID", u(session)), ("localID", u(local))]) } @@ -1400,25 +1436,7 @@ impl<'d> Exporter<'d> { ("stackReverseZIndex", b(l.reverse_z)), ]); if grid { - // Instance layers share their main layer's track ids, so unchanged tracks and the cells - // their layers sit in still match. - let key = self.main_layer(n.id); - let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize); - let (cols, rows) = match self.tracks.get(&key) { - Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()), - _ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()), - }; - let map = |ids: &[u32]| { - o(vec![( - "entries", - arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()), - )]) - }; - f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); - if l.column_tracks.iter().chain(&l.row_tracks).any(|t| t.sizing != Sizing::Fill) { - self.warn("grid tracks of fixed or content size were written as equal tracks"); - } - self.tracks.insert(key, (cols, rows)); + self.grid_layout(n, l, f); } else { let spread = l.content_align == ContentAlign::SpaceBetween; f.extend([ @@ -1429,6 +1447,34 @@ impl<'d> Exporter<'d> { } } + /// A grid layout's track gaps, order and sizes. + fn grid_layout(&mut self, n: &Node, l: &AutoLayout, f: &mut Fields) { + // Instance layers share their main layer's track ids, so unchanged tracks and the cells + // their layers sit in still match. + let key = self.main_layer(n.id); + let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize); + let (cols, rows) = match self.tracks.get(&key) { + Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()), + _ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()), + }; + let map = |ids: &[u32]| { + o(vec![("entries", arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()))]) + }; + f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); + let mut dropped = false; + let column_sizing = grid_sizing(&l.column_tracks, &cols, &mut dropped); + let row_sizing = grid_sizing(&l.row_tracks, &rows, &mut dropped); + if dropped { + self.warn("grid tracks whose size the format can't hold were written as equal tracks"); + } + for (name, sizing) in [("gridColumnsSizing", column_sizing), ("gridRowsSizing", row_sizing)] { + if let Some(v) = sizing { + f.push((name, v)); + } + } + self.tracks.insert(key, (cols, rows)); + } + /// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own /// and are written whatever its parent is. fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { @@ -2374,7 +2420,7 @@ fn unset(key: &str) -> Option> { "styleIdForFill" | "styleIdForStrokeFill" | "styleIdForEffect" | "styleIdForGrid" | "styleIdForText" => { o(vec![("guid", guid(u32::MAX, u32::MAX))]) } - "variableConsumptionMap" | "variableModeBySetMap" => o(vec![("entries", arr(Vec::new()))]), + "variableConsumptionMap" | "variableModeBySetMap" | "gridColumnsSizing" | "gridRowsSizing" => o(vec![("entries", arr(Vec::new()))]), "mask" | "rectangleCornerRadiiIndependent" | "borderStrokeWeightsIndependent" => b(false), "effects" | "dashPattern" | "layoutGrids" | "exportSettings" | "prototypeInteractions" | "fillGeometry" | "variableWidthPoints" => { arr(Vec::new()) diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 0eac9571..1f0fe172 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -74,7 +74,7 @@ fn paint(r: &mut Lcg, image: &str) -> Paint { p.kind } }; - Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS) } + Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS), color_alpha: 1.0 } } fn effect(r: &mut Lcg) -> Effect { @@ -1022,6 +1022,81 @@ fn junk_documents_never_panic() { } } +/// Grid track sizes reach the file and come back: fixed px, fractional fill and hug tracks, and a +/// track the format has no name for leaves an equal track behind instead of failing the write. +#[test] +fn grid_track_sizes_round_trip() { + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let grid = doc.create(page, NodeKind::Frame, "Grid", 0.0, 0.0, 400.0, 200.0).unwrap(); + { + let node = doc.node_mut(grid).unwrap(); + node.layout = Some(AutoLayout { + mode: LayoutMode::Grid, + grid_columns: 3, + grid_rows: 2, + column_tracks: vec![GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()], + row_tracks: vec![GridTrack::fill(1.0), GridTrack::fixed(50.0)], + ..AutoLayout::default() + }); + } + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let (back, iw) = import(&bytes, "File").unwrap(); + assert!(iw.is_empty(), "{iw:?}"); + let l = back.node(back.pages[0]).and_then(|p| p.children.first()).and_then(|c| back.node(*c)).and_then(|n| n.layout.clone()).unwrap(); + assert_eq!(l.column_tracks, vec![GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()]); + assert_eq!(l.row_tracks, vec![GridTrack::fill(1.0), GridTrack::fixed(50.0)]); + + // A negative fixed track is no size the format can name: the write still succeeds, the track + // falls back to an equal one, and the warning says so. + let mut odd = doc.clone(); + if let Some(l) = odd.node_mut(grid).and_then(|n| n.layout.as_mut()) { + l.column_tracks[0] = GridTrack::fixed(-5.0); + } + let (bytes, warnings) = export(&odd, None).unwrap(); + assert_eq!(warnings, vec!["grid tracks whose size the format can't hold were written as equal tracks".to_string()]); + let (back, _) = import(&bytes, "File").unwrap(); + let l = back.node(back.pages[0]).and_then(|p| p.children.first()).and_then(|c| back.node(*c)).and_then(|n| n.layout.clone()).unwrap(); + assert_eq!(l.column_tracks[0], GridTrack::fill(1.0), "an unwritable track reads back equal"); +} + +/// An instance layer whose grid tracks are equal doesn't read back its main layer's track sizes. +#[test] +fn instance_grid_tracks_clear_the_mains_sizes() { + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let main = doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Main", 0.0, 0.0, 400.0, 200.0).unwrap(); + let grid = doc.create(main, NodeKind::Frame, "Grid", 0.0, 0.0, 400.0, 200.0).unwrap(); + let layout = AutoLayout { + mode: LayoutMode::Grid, + grid_columns: 2, + grid_rows: 1, + column_tracks: vec![GridTrack::fixed(120.0); 2], + ..AutoLayout::default() + }; + doc.node_mut(grid).unwrap().layout = Some(layout.clone()); + let inst = doc.alloc_id(); + let mut n = doc.node(main).unwrap().clone(); + (n.id, n.kind, n.parent, n.children) = (inst, NodeKind::Instance { component: main, props: BTreeMap::new() }, None, Vec::new()); + doc.insert(n, page, None).unwrap(); + let mut layer = doc.node(grid).unwrap().clone(); + (layer.id, layer.parent, layer.source) = (doc.alloc_id(), None, Some(grid)); + layer.layout = Some(AutoLayout { column_tracks: Vec::new(), ..layout }); + doc.insert(layer, inst, None).unwrap(); + let (bytes, _) = export(&doc, None).unwrap(); + let (back, _) = import(&bytes, "File").unwrap(); + let tracks = |inside: bool| { + let n = back.nodes().find(|n| { + n.name == "Grid" + && back.ancestors(n.id).iter().any(|a| matches!(back.node(*a).map(|p| &p.kind), Some(NodeKind::Instance { .. }))) == inside + }); + n.and_then(|n| n.layout.clone()).unwrap().column_tracks + }; + assert_eq!(tracks(false), vec![GridTrack::fixed(120.0); 2]); + assert!(tracks(true).iter().all(|t| *t == GridTrack::fill(1.0)), "{:?}", tracks(true)); +} + /// A frame's own min/max size survives whatever its parent is, not only under auto layout. #[test] fn frame_size_limits_survive_without_auto_layout_parent() { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 8e467151..ec0dd8e4 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -24,7 +24,7 @@ const MAX_DEPTH: usize = 256; /// Most path commands read from one geometry blob. const MAX_COMMANDS: usize = 200_000; const MAX_GRID_TRACKS: usize = 1_000; -const MAX_GRID_TRACK_VALUE: f64 = 1e6; +pub(super) const MAX_GRID_TRACK_VALUE: f64 = 1e6; /// Id of "no style". const NO_STYLE: &str = "4294967295:4294967295"; /// Longest asset `key` or `version` kept for resolving references given as assets. diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index efb7710d..f5be3d27 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -123,10 +123,13 @@ pub(super) fn paints(cx: &mut Ctx, v: &Value, k: &str) -> Option> { fn paint(cx: &mut Ctx, p: &Value) -> Option { let ty = text(p, "type")?; let mut opacity = num(p, "opacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32; + // The colour's alpha goes into the opacity as for a typed colour (`Paint::solid`). + let mut color_alpha = 1.0; let kind = match ty { "SOLID" => { let c = p.get("color").and_then(color).unwrap_or(Color::BLACK); opacity *= c.a; + color_alpha = c.a; PaintKind::Solid { color: c.with_alpha(1.0) } } "GRADIENT_LINEAR" => PaintKind::Gradient(gradient(GradientKind::Linear, p)), @@ -139,7 +142,7 @@ fn paint(cx: &mut Ctx, p: &Value) -> Option { return None; } }; - Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p) }) + Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p), color_alpha }) } /// Gradient handles are already in the node's unit box (start, end, width), like ours. diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 1b3f85b6..f9acf2ea 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -162,6 +162,7 @@ fn paints_gradients_and_image_placeholders() { let s = &named(&doc, "Solid").fills[0]; assert!(matches!(s.kind, PaintKind::Solid { color } if color.r == 1.0 && color.a == 1.0)); assert!(close(f64::from(s.opacity), 0.25), "paint opacity × colour alpha"); + assert!(close(f64::from(s.color_alpha), 0.5), "the colour's part, which a bound variable replaces"); assert!(!s.visible && s.blend == BlendMode::Screen); let PaintKind::Gradient(g) = &named(&doc, "Linear").fills[0].kind else { panic!() }; assert_eq!((g.kind, g.from, g.to, g.width), (GradientKind::Linear, (0.0, 0.5), (1.0, 0.5), (0.0, 1.0))); diff --git a/crates/format/src/thumbnail.rs b/crates/format/src/thumbnail.rs index a5e7e2b3..76ebcf4e 100644 --- a/crates/format/src/thumbnail.rs +++ b/crates/format/src/thumbnail.rs @@ -109,6 +109,8 @@ pub fn file_thumbnail(path: &Path, fit: u32) -> Option { pub struct Preview { pub page: Option<[f32; 4]>, pub picture: Option, + /// A design file or a board (`.fig` files and images: design). + pub kind: figcraft_doc::FileKind, } /// The [`Preview`] of the file at `path`, its picture at most `fit` pixels on its longer side. @@ -119,7 +121,7 @@ pub fn file_preview(path: &Path, fit: u32) -> Preview { let Ok(file) = std::fs::File::open(path) else { return Preview::default() }; archive_preview(std::io::BufReader::new(file), fit) } - FileKind::Image => Preview { page: None, picture: file_thumbnail(path, fit) }, + FileKind::Image => Preview { picture: file_thumbnail(path, fit), ..Preview::default() }, FileKind::Svg | FileKind::Other => Preview::default(), } } @@ -129,31 +131,33 @@ pub fn file_preview(path: &Path, fit: u32) -> Preview { pub fn archive_preview(archive: R, fit: u32) -> Preview { let Ok(mut z) = zip::ZipArchive::new(archive) else { return Preview::default() }; let picture = crate::read_member(&mut z, "thumbnail.png", MAX_THUMBNAIL_BYTES).ok().and_then(|png| decode_thumbnail(&png, fit)); - let page = if z.file_names().any(|n| n == "document.json") { - crate::read_member(&mut z, "document.json", MAX_DOCUMENT_BYTES).ok().and_then(|json| figcraft_page(&json)) + let (page, kind) = if z.file_names().any(|n| n == "document.json") { + crate::read_member(&mut z, "document.json", MAX_DOCUMENT_BYTES).ok().map(|json| figcraft_page(&json)).unwrap_or_default() } else { - crate::read_member(&mut z, "meta.json", MAX_META_BYTES).ok().and_then(|json| fig_page(&json)) + (crate::read_member(&mut z, "meta.json", MAX_META_BYTES).ok().and_then(|json| fig_page(&json)), figcraft_doc::FileKind::Design) }; - Preview { page, picture } + Preview { page, picture, kind } } -/// The first page's background in a `.figcraft` `document.json`. -fn figcraft_page(json: &[u8]) -> Option<[f32; 4]> { +/// The first page's background in a `.figcraft` `document.json`, and the file's kind. +fn figcraft_page(json: &[u8]) -> (Option<[f32; 4]>, figcraft_doc::FileKind) { #[derive(serde::Deserialize)] struct Head { #[serde(default)] pages: Vec, #[serde(default)] nodes: std::collections::BTreeMap, + #[serde(default)] + kind: figcraft_doc::FileKind, } #[derive(serde::Deserialize)] struct Node { #[serde(default)] background: Option, } - let head: Head = serde_json::from_slice(json).ok()?; - let first = head.pages.first()?; - rgba(head.nodes.get(&first.to_string())?.background.as_ref()?) + let Ok(head) = serde_json::from_slice::(json) else { return (None, figcraft_doc::FileKind::Design) }; + let page = head.pages.first().and_then(|first| head.nodes.get(&first.to_string())).and_then(|n| n.background.as_ref()).and_then(rgba); + (page, head.kind) } /// The canvas colour a `.fig` file's `meta.json` gives (`client_meta.background_color`). diff --git a/crates/format/src/thumbnail_tests.rs b/crates/format/src/thumbnail_tests.rs index 864c0f24..eeaeaba5 100644 --- a/crates/format/src/thumbnail_tests.rs +++ b/crates/format/src/thumbnail_tests.rs @@ -148,7 +148,12 @@ fn previews_carry_the_first_pages_colour() { assert!(p.picture.is_some()); // A file with nothing on it: its colour, no picture. let bytes = crate::save_bytes(&doc_on([0.9, 0.9, 0.9]), None).unwrap(); - assert_eq!(archive_preview(Cursor::new(&bytes), 640), Preview { page: Some([0.9, 0.9, 0.9, 1.0]), picture: None }); + assert_eq!(archive_preview(Cursor::new(&bytes), 640), Preview { page: Some([0.9, 0.9, 0.9, 1.0]), picture: None, kind: Default::default() }); + // A board says so (the home screen marks it). + let mut board = doc_on([0.9, 0.9, 0.9]); + board.kind = figcraft_doc::FileKind::Board; + let bytes = crate::save_bytes(&board, None).unwrap(); + assert_eq!(archive_preview(Cursor::new(&bytes), 640).kind, figcraft_doc::FileKind::Board); // Damaged or odd metadata: no colour, never a panic. for meta in [&b"{"[..], b"{\"client_meta\":{\"background_color\":{\"r\":\"x\",\"g\":0,\"b\":0}}}", b"[]", b"{\"client_meta\":7}"] { assert_eq!(archive_preview(Cursor::new(zip(&[("meta.json", meta)])), 640).page, None, "{}", String::from_utf8_lossy(meta)); diff --git a/crates/format/tests/distributed_alignment.rs b/crates/format/tests/it/distributed_alignment.rs similarity index 100% rename from crates/format/tests/distributed_alignment.rs rename to crates/format/tests/it/distributed_alignment.rs diff --git a/crates/format/tests/import_link_contract.rs b/crates/format/tests/it/import_link_contract.rs similarity index 100% rename from crates/format/tests/import_link_contract.rs rename to crates/format/tests/it/import_link_contract.rs diff --git a/crates/format/tests/it/main.rs b/crates/format/tests/it/main.rs new file mode 100644 index 00000000..6286e8db --- /dev/null +++ b/crates/format/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, built as one test binary. + +mod distributed_alignment; +mod import_link_contract; +mod range_hyperlink_round_trip; diff --git a/crates/format/tests/range_hyperlink_round_trip.rs b/crates/format/tests/it/range_hyperlink_round_trip.rs similarity index 100% rename from crates/format/tests/range_hyperlink_round_trip.rs rename to crates/format/tests/it/range_hyperlink_round_trip.rs diff --git a/crates/geom/src/measure.rs b/crates/geom/src/measure.rs index 7acfe4ed..0c24aa9d 100644 --- a/crates/geom/src/measure.rs +++ b/crates/geom/src/measure.rs @@ -91,6 +91,27 @@ pub fn polylines(path: &BezPath) -> Option> { polylines_with_limit(path, MAX_SAMPLES) } +fn flatten_curve_segment(el: PathEl, seg_start: Point, limit: usize, count: &mut usize, cur: &mut Polyline) -> bool { + let mut over = false; + kurbo::flatten([PathEl::MoveTo(seg_start), el], FLATTEN_TOL, |fel| { + if over { + return; + } + if let PathEl::LineTo(p) = fel { + *count += 1; + if *count > limit { + over = true; + return; + } + if cur.points.is_empty() { + cur.push(seg_start); + } + cur.push(p); + } + }); + !over +} + /// [`polylines`] with an explicit sample budget, so tests can use a small one. /// /// The budget is enforced *while* flattening, not after it (issue #204): the input element @@ -141,25 +162,7 @@ fn polylines_with_limit(path: &BezPath, limit: usize) -> Option> { PathEl::QuadTo(_, end) | PathEl::CurveTo(_, _, end) => { // Flatten this one segment on its own, so an over-budget curve aborts // here instead of after the whole path has been flattened. - let seg_start = pen; - let mut over = false; - kurbo::flatten([PathEl::MoveTo(seg_start), *el], FLATTEN_TOL, |fel| { - if over { - return; - } - if let PathEl::LineTo(p) = fel { - count += 1; - if count > limit { - over = true; - return; - } - if cur.points.is_empty() { - cur.push(seg_start); - } - cur.push(p); - } - }); - if over { + if !flatten_curve_segment(*el, pen, limit, &mut count, &mut cur) { return None; } pen = end; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index aad3530e..9b8bf7be 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -59,7 +59,7 @@ fn error(id: Value, code: i64, message: impl Into) -> Value { } fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { - payload.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| payload.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) + payload.downcast_ref::<&str>().map(ToString::to_string).or_else(|| payload.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) } impl Server { diff --git a/crates/mcp/tests/agent_tasks.rs b/crates/mcp/tests/it/agent_tasks.rs similarity index 99% rename from crates/mcp/tests/agent_tasks.rs rename to crates/mcp/tests/it/agent_tasks.rs index b35f3e7f..b83fc3b8 100644 --- a/crates/mcp/tests/agent_tasks.rs +++ b/crates/mcp/tests/it/agent_tasks.rs @@ -1,8 +1,7 @@ //! End-to-end agent tasks: an in-process MCP client completes realistic design tasks only through //! MCP tool calls, and checks every result through inspect_document / get_node like an agent would. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; +use crate::common; use common::{Client, approx, png_dims}; use serde_json::{Value, json}; diff --git a/crates/mcp/tests/attached.rs b/crates/mcp/tests/it/attached.rs similarity index 99% rename from crates/mcp/tests/attached.rs rename to crates/mcp/tests/it/attached.rs index 69194604..f1343dd2 100644 --- a/crates/mcp/tests/attached.rs +++ b/crates/mcp/tests/it/attached.rs @@ -1,6 +1,5 @@ //! The attached backend against a fake app: a loopback control server answering with a headless //! session, speaking the desktop app's JSON-lines protocol. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/it/automation_roots.rs similarity index 98% rename from crates/mcp/tests/automation_roots.rs rename to crates/mcp/tests/it/automation_roots.rs index ced1e145..aa8e1b24 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/it/automation_roots.rs @@ -1,6 +1,5 @@ //! `--automation-read-root` / `--automation-write-root` over MCP (#715): a tool call reads only //! inside the read and write roots and writes only inside the write roots. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)] use std::path::PathBuf; diff --git a/crates/mcp/tests/common/mod.rs b/crates/mcp/tests/it/common/mod.rs similarity index 97% rename from crates/mcp/tests/common/mod.rs rename to crates/mcp/tests/it/common/mod.rs index be4dcea2..51e74865 100644 --- a/crates/mcp/tests/common/mod.rs +++ b/crates/mcp/tests/it/common/mod.rs @@ -1,5 +1,4 @@ //! An in-process MCP client: feeds JSON-RPC lines to `Server::handle_line`. -#![allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)] use figcraft_mcp::Server; use serde_json::{Value, json}; diff --git a/crates/mcp/tests/export_hidden_roots.rs b/crates/mcp/tests/it/export_hidden_roots.rs similarity index 96% rename from crates/mcp/tests/export_hidden_roots.rs rename to crates/mcp/tests/it/export_hidden_roots.rs index ded6f731..0a3b8de4 100644 --- a/crates/mcp/tests/export_hidden_roots.rs +++ b/crates/mcp/tests/it/export_hidden_roots.rs @@ -1,7 +1,6 @@ //! Hidden top-level layers in an export take no room in the image (issue #447). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; +use crate::common; use common::{Client, png_dims}; use serde_json::{Value, json}; diff --git a/crates/mcp/tests/garbage.rs b/crates/mcp/tests/it/garbage.rs similarity index 98% rename from crates/mcp/tests/garbage.rs rename to crates/mcp/tests/it/garbage.rs index 262934c7..5d9e237e 100644 --- a/crates/mcp/tests/garbage.rs +++ b/crates/mcp/tests/it/garbage.rs @@ -1,5 +1,4 @@ //! Fuzz-ish: no input line may crash the server or leave it unable to answer. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use figcraft_mcp::{Server, TOOL_NAMES}; use proptest::prelude::*; diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs new file mode 100644 index 00000000..b5e3d89d --- /dev/null +++ b/crates/mcp/tests/it/main.rs @@ -0,0 +1,18 @@ +//! The crate's integration tests, built as one test binary. + +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod agent_tasks; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod attached; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)] +mod automation_roots; +#[allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod common; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod export_hidden_roots; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod garbage; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod protocol; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod screenshot_pixel_limit; diff --git a/crates/mcp/tests/protocol.rs b/crates/mcp/tests/it/protocol.rs similarity index 99% rename from crates/mcp/tests/protocol.rs rename to crates/mcp/tests/it/protocol.rs index 6e943a1e..617790f4 100644 --- a/crates/mcp/tests/protocol.rs +++ b/crates/mcp/tests/it/protocol.rs @@ -1,7 +1,6 @@ //! JSON-RPC / MCP protocol behaviour. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; +use crate::common; use common::Client; use figcraft_mcp::{PROTOCOL_VERSION, Server, TOOL_NAMES}; diff --git a/crates/mcp/tests/screenshot_pixel_limit.rs b/crates/mcp/tests/it/screenshot_pixel_limit.rs similarity index 96% rename from crates/mcp/tests/screenshot_pixel_limit.rs rename to crates/mcp/tests/it/screenshot_pixel_limit.rs index 095992ff..e575f1d5 100644 --- a/crates/mcp/tests/screenshot_pixel_limit.rs +++ b/crates/mcp/tests/it/screenshot_pixel_limit.rs @@ -1,6 +1,5 @@ //! `screenshot` with `ids` must honour `maxSize` for the rendered paint area (shadows, overflow). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; +use crate::common; use common::{Client, png_dims}; use serde_json::{Value, json}; diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index fb9cfcfe..324af4d4 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -13,6 +13,8 @@ mod brush_tests; mod ops_tests; mod points; #[cfg(test)] +mod rounded_side_strokes_tests; +#[cfg(test)] mod sides_tests; pub mod width; #[cfg(test)] @@ -451,6 +453,104 @@ pub fn rect_side_strokes(w: f64, h: f64, sides: [f64; 4], align: StrokeAlign) -> p } +/// Per-side stroke of a rectangle with corner radii `[tl, tr, br, bl]`, like CSS borders: the outer +/// edge is the rounded rectangle (center and outside grow its box by half or all of each side's +/// weight, and each non-zero radius with it); the inner edge is inset by each side's weight, with +/// elliptical corners `max(0, R − weight)` across and down (square where either is 0). Corners +/// that stay circular are smoothed like the shape's. An even-odd ring whose inner edge runs against +/// the outer one, so it is a ring under non-zero too. +pub fn rounded_side_strokes(w: f64, h: f64, radii: [f64; 4], smoothing: f64, sides: [f64; 4], align: StrokeAlign) -> BezPath { + let [t, r, b, l] = sides.map(non_negative); + if t + r + b + l <= 0.0 { + return BezPath::new(); + } + let f = match align { + StrokeAlign::Inside => 0.0, + StrokeAlign::Center => 0.5, + StrokeAlign::Outside => 1.0, + }; + // Clamped like the fill: at most half the shorter side. + let max_r = non_negative(w.min(h)) / 2.0; + let radii = radii.map(|v| non_negative(v).min(max_r)); + // Each corner's radius with the weights across and down it, clockwise from the top-left. + let corners = [(radii[0], l, t), (radii[1], r, t), (radii[2], r, b), (radii[3], l, b)]; + let outer = if align == StrokeAlign::Inside { + figcraft_geom::rounded_rect(w, h, radii, smoothing) + } else { + let grown = corners.map(|(rr, x, y)| if rr > 0.0 { (rr + x * f, rr + y * f) } else { (0.0, 0.0) }); + corner_rect(figcraft_geom::Rect::new(-l * f, -t * f, w + r * f, h + b * f), grown, smoothing) + }; + let k = 1.0 - f; + let inner = figcraft_geom::Rect::new(l * k, t * k, w - r * k, h - b * k); + if inner.width() <= 0.0 || inner.height() <= 0.0 { + return outer; + } + let (inset, fit) = fit_corners(inner, corners.map(|(rr, x, y)| ((rr - x * k).max(0.0), (rr - y * k).max(0.0)))); + // The hole's corners sit on the outer ones, inside them, unless they were shrunk to fit or + // smoothed: then it may leave the outer edge. + ring(outer, &corner_rect(inner, inset, smoothing), fit < 1.0 || smoothing > 0.0) +} + +/// Rectangle `r` with `(rx, ry)` corners, smoothed like `figcraft_geom::rounded_rect` where every +/// corner is circular (smoothing is defined for those only). +fn corner_rect(r: figcraft_geom::Rect, corners: [(f64, f64); 4], smoothing: f64) -> BezPath { + if smoothing > 0.0 && corners.iter().all(|(rx, ry)| rx == ry) { + return kurbo::Affine::translate((r.x0, r.y0)) * figcraft_geom::rounded_rect(r.width(), r.height(), corners.map(|c| c.0), smoothing); + } + elliptical_rect(r, corners) +} + +fn non_negative(v: f64) -> f64 { + if v.is_finite() { v.max(0.0) } else { 0.0 } +} + +/// `outer` with `hole` taken out: cut out of it when the hole may leave it, else as an even-odd +/// ring whose hole runs against it. +fn ring(outer: BezPath, hole: &BezPath, cut: bool) -> BezPath { + if cut && let Ok(p) = boolean_step(&outer, hole, BoolOp::Subtract) { + return p; + } + let mut p = outer; + p.extend(hole.reverse_subpaths().elements().iter().copied()); + p +} + +/// Corner radii `(rx, ry)` for the inner box `inner`, square where either is 0, all shrunk by one +/// factor (returned too) where two on an edge add up to more than it, as CSS shrinks radii. +fn fit_corners(inner: figcraft_geom::Rect, corners: [(f64, f64); 4]) -> ([(f64, f64); 4], f64) { + let c = corners.map(|(rx, ry)| if rx > 0.0 && ry > 0.0 { (rx, ry) } else { (0.0, 0.0) }); + let [tl, tr, br, bl] = c; + let edges = [(inner.width(), tl.0 + tr.0), (inner.width(), bl.0 + br.0), (inner.height(), tl.1 + bl.1), (inner.height(), tr.1 + br.1)]; + let fit = edges.iter().filter(|(_, sum)| *sum > 0.0).fold(1.0_f64, |m, (len, sum)| m.min(len / sum)); + (c.map(|(rx, ry)| (rx * fit, ry * fit)), fit) +} + +/// Rectangle `r` with an `(rx, ry)` quarter ellipse at each corner `[tl, tr, br, bl]`, drawn like +/// `figcraft_geom::rounded_rect` (circular corners come out the same). +fn elliptical_rect(r: figcraft_geom::Rect, [tl, tr, br, bl]: [(f64, f64); 4]) -> BezPath { + use std::f64::consts::{FRAC_PI_2, PI}; + // Each corner, the direction of its ellipse centre from it, and the angle its quarter starts at. + let quarters = [ + (Point::new(r.x1, r.y0), (-1.0, 1.0), tr, -FRAC_PI_2), + (Point::new(r.x1, r.y1), (-1.0, -1.0), br, 0.0), + (Point::new(r.x0, r.y1), (1.0, -1.0), bl, FRAC_PI_2), + (Point::new(r.x0, r.y0), (1.0, 1.0), tl, PI), + ]; + let mut p = BezPath::new(); + p.move_to((r.x0 + tl.0, r.y0)); + for (corner, (sx, sy), (rx, ry), start) in quarters { + if rx > 0.0 && ry > 0.0 { + let c = Point::new(corner.x + sx * rx, corner.y + sy * ry); + p.line_to((c.x + rx * start.cos(), c.y + ry * start.sin())); + kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0).to_cubic_beziers(0.01, |a, b, e| p.curve_to(a, b, e)); + } else { + p.line_to(corner); + } + } + p.close_path(); + p +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pathops/src/rounded_side_strokes_tests.rs b/crates/pathops/src/rounded_side_strokes_tests.rs new file mode 100644 index 00000000..9c2f387e --- /dev/null +++ b/crates/pathops/src/rounded_side_strokes_tests.rs @@ -0,0 +1,124 @@ +//! Per-side stroke weights on rounded rectangles, drawn like CSS borders: square corners, a bar +//! along a rounded edge, tapers through corners, alignments, inner corners that no longer fit, +//! smoothing and degenerate weights. + +use figcraft_doc::StrokeAlign; +use figcraft_geom::kurbo::{BezPath, Point, Shape}; +use figcraft_geom::rounded_rect; + +use crate::{rect_side_strokes, rounded_side_strokes}; + +const W: f64 = 120.0; +const H: f64 = 60.0; + +/// The area of a ring whose hole runs against its outer edge. +fn area(p: &BezPath) -> f64 { + p.area().abs() +} + +fn painted(p: &BezPath, x: f64, y: f64) -> bool { + p.winding(Point::new(x, y)) % 2 != 0 +} + +#[test] +fn square_corners_paint_what_rect_side_strokes_paints() { + for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] { + let sides = [1.0, 2.0, 3.0, 4.0]; + let a = area(&rounded_side_strokes(W, H, [0.0; 4], 0.0, sides, align)); + assert!((a - area(&rect_side_strokes(W, H, sides, align))).abs() < 1e-6, "{align:?}: {a}"); + } +} + +/// `border-l-4 rounded-r-lg`. +#[test] +fn a_left_border_on_a_right_rounded_box_is_a_straight_bar() { + let bar = rounded_side_strokes(W, H, [0.0, 8.0, 8.0, 0.0], 0.0, [0.0, 0.0, 0.0, 4.0], StrokeAlign::Inside); + assert!((area(&bar) - 4.0 * H).abs() < 1e-6, "{}", area(&bar)); + assert!(painted(&bar, 2.0, 30.0)); + for (x, y) in [(119.9, 30.0), (117.0, 1.0), (60.0, 0.1), (60.0, 59.9), (4.1, 30.0)] { + assert!(!painted(&bar, x, y), "({x}, {y})"); + } +} + +#[test] +fn one_weight_on_every_side_is_the_uniform_ring() { + let ring = rounded_side_strokes(W, H, [8.0; 4], 0.0, [2.0; 4], StrokeAlign::Inside); + let want = area(&rounded_rect(W, H, [8.0; 4], 0.0)) - area(&rounded_rect(W - 4.0, H - 4.0, [6.0; 4], 0.0)); + assert!((area(&ring) - want).abs() < 1e-6, "{} vs {want}", area(&ring)); + assert_eq!(ring.winding(Point::new(60.0, 30.0)), 0, "a hole under non-zero too"); +} + +#[test] +fn center_and_outside_grow_the_box_and_its_corners() { + for (align, out, inset) in [(StrokeAlign::Center, 1.0, 1.0), (StrokeAlign::Outside, 2.0, 0.0)] { + let a = area(&rounded_side_strokes(W, H, [8.0; 4], 0.0, [2.0; 4], align)); + let outer = area(&rounded_rect(W + 2.0 * out, H + 2.0 * out, [8.0 + out; 4], 0.0)); + let hole = area(&rounded_rect(W - 2.0 * inset, H - 2.0 * inset, [8.0 - inset; 4], 0.0)); + assert!((a - (outer - hole)).abs() < 1e-6, "{align:?}: {a} vs {}", outer - hole); + } +} + +/// 8 × 7 inner ellipses at the bottom corners. +#[test] +fn a_bottom_border_tapers_into_the_bottom_corners() { + let bottom = rounded_side_strokes(W, H, [8.0; 4], 0.0, [0.0, 0.0, 1.0, 0.0], StrokeAlign::Inside); + assert!(painted(&bottom, 60.0, 59.5) && painted(&bottom, 8.0, 59.5) && painted(&bottom, 112.0, 59.5)); + for (x, y) in [(60.0, 0.5), (0.5, 30.0), (0.5, 52.0), (119.5, 52.0), (4.0, 4.0)] { + assert!(!painted(&bottom, x, y), "({x}, {y})"); + } +} + +#[test] +fn a_weight_past_the_radius_leaves_square_inner_corners() { + let top = rounded_side_strokes(W, H, [8.0; 4], 0.0, [10.0, 0.0, 0.0, 2.0], StrokeAlign::Inside); + assert!(!painted(&top, 2.2, 10.2) && !painted(&top, 119.8, 10.2)); + assert!(painted(&top, 60.0, 9.8) && painted(&top, 1.8, 30.0)); +} + +/// A weight past `w − R` leaves inner corners wider than the inner box: they shrink to fit, so the +/// hole stays inside the outer edge, across or down. +#[test] +fn inner_corners_shrink_to_fit_a_narrow_inner_box() { + let wide = rounded_side_strokes(20.0, 20.0, [10.0; 4], 0.0, [0.0, 0.0, 0.0, 12.0], StrokeAlign::Inside); + assert!(painted(&wide, 6.0, 10.0) && !painted(&wide, 16.0, 10.0)); + assert!(!painted(&wide, 17.5, 3.0) && !painted(&wide, 17.5, 17.0), "nothing past the outer edge"); + let tall = rounded_side_strokes(20.0, 20.0, [10.0; 4], 0.0, [12.0, 0.0, 0.0, 0.0], StrokeAlign::Inside); + assert!(painted(&tall, 10.0, 6.0) && !painted(&tall, 10.0, 16.0)); + assert!(!painted(&tall, 3.0, 17.5) && !painted(&tall, 17.0, 17.5), "nothing past the outer edge"); + let pill = rounded_side_strokes(30.0, 20.0, [10.0; 4], 0.0, [0.0, 0.0, 0.0, 25.0], StrokeAlign::Inside); + assert!(painted(&pill, 12.0, 10.0) && !painted(&pill, 27.0, 10.0)); + assert!(!painted(&pill, 24.0, 0.5) && !painted(&pill, 24.0, 19.5), "nothing past the outer edge"); +} + +#[test] +fn smoothed_corners_keep_the_stroke_inside_the_fill() { + let smooth = rounded_side_strokes(W, H, [16.0; 4], 0.6, [0.0, 0.0, 2.0, 0.0], StrokeAlign::Inside); + let fill = rounded_rect(W, H, [16.0; 4], 0.6); + let grid = (0..1200).flat_map(|i| (0..600).map(move |j| Point::new(f64::from(i) * 0.1 + 0.05, f64::from(j) * 0.1 + 0.05))); + let stray = grid.filter(|pt| painted(&smooth, pt.x, pt.y) && (pt.y < H / 2.0 || fill.winding(*pt) == 0)); + assert_eq!(stray.count(), 0, "painted outside the fill or along the top"); + assert!(painted(&smooth, 60.0, 59.5)); +} + +#[test] +fn center_and_outside_strokes_follow_smoothed_corners() { + for (align, out, inset) in [(StrokeAlign::Center, 1.0, 1.0), (StrokeAlign::Outside, 2.0, 0.0)] { + let a = area(&rounded_side_strokes(W, H, [16.0; 4], 0.6, [2.0; 4], align)); + let outer = area(&rounded_rect(W + 2.0 * out, H + 2.0 * out, [16.0 + out; 4], 0.6)); + let hole = area(&rounded_rect(W - 2.0 * inset, H - 2.0 * inset, [16.0 - inset; 4], 0.6)); + assert!((a - (outer - hole)).abs() < 1e-3, "{align:?}: {a} vs {}", outer - hole); + } +} + +#[test] +fn weights_wider_than_the_box_leave_the_outer_edge() { + let full = rounded_side_strokes(20.0, 10.0, [4.0; 4], 0.0, [6.0; 4], StrokeAlign::Inside); + assert!((area(&full) - area(&rounded_rect(20.0, 10.0, [4.0; 4], 0.0))).abs() < 1e-6); +} + +#[test] +fn no_weight_paints_nothing() { + assert!(rounded_side_strokes(W, H, [8.0; 4], 0.0, [0.0; 4], StrokeAlign::Inside).elements().is_empty()); + let junk = [f64::NAN, -4.0, f64::INFINITY, 0.0]; + assert!(rounded_side_strokes(W, H, [8.0; 4], 0.0, junk, StrokeAlign::Inside).elements().is_empty()); +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 3656cf1e..a696e279 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -271,3 +271,30 @@ fn sample_files() { std::fs::write(format!("{stem}-{}.pdf", i + 1), export_pdf(&d, &[*f], &PdfOptions::default()).unwrap()).unwrap(); } } + +/// A tiny PNG whose header claims 30,000 × 30,000 RGBA (3.6 GB decoded) is refused when it is read, +/// before anything is decoded: the PDF writer is craft-libs' patched krilla. +#[test] +fn a_png_that_decodes_too_large_is_refused() { + fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend_from_slice(kind); + out.extend_from_slice(data); + out.extend_from_slice(&crc.sum().to_be_bytes()); + } + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&30_000u32.to_be_bytes()); + ihdr.extend_from_slice(&30_000u32.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut z, &[0; 64]).unwrap(); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut png, b"IHDR", &ihdr); + chunk(&mut png, b"IDAT", &z.finish().unwrap()); + chunk(&mut png, b"IEND", &[]); + const { assert!(krilla::CRAFT_UNWIND_SAFE_DROP) }; + assert!(krilla::image::Image::from_png(krilla::Data::from(png), true).is_err()); +} diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index 48e44e88..4bd3590f 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -44,7 +44,7 @@ pub struct Manifest { pub main: String, /// The plugin API version the plugin was written for (1 when not given; panels and settings /// need 2, widgets 3). - #[serde(default = "api_version")] + #[serde(default = "api_version", deserialize_with = "api_from_number_or_string")] pub api: u32, #[serde(default)] pub permissions: Permissions, @@ -61,6 +61,25 @@ fn api_version() -> u32 { 1 } +/// `api` as a whole number or a version string (`"1.0.0"`, whose major part is the API version). +fn api_from_number_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Api { + Number(u32), + Text(String), + } + match Api::deserialize(d)? { + Api::Number(n) => Ok(n), + Api::Text(s) => s + .trim() + .split('.') + .next() + .and_then(|major| major.parse().ok()) + .ok_or_else(|| serde::de::Error::custom(format!("invalid api version `{s}`"))), + } +} + /// What a plugin may do. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Permissions { diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index 5d094ab4..a6e2a587 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -462,3 +462,12 @@ fn bad_manifests_are_refused() { assert!(Manifest::parse(junk).is_err(), "accepted {}", junk.chars().take(40).collect::()); } } + +#[test] +fn api_may_be_a_version_string() { + assert_eq!(manifest(json!({"api": "1.0.0"})).unwrap().api, 1); + assert_eq!(manifest(json!({"api": "2"})).unwrap().api, 2); + assert_eq!(manifest(json!({"api": 2})).unwrap().api, 2); + assert!(manifest(json!({"api": "4.0.0"})).is_err()); + assert!(manifest(json!({"api": "x"})).is_err()); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 63b38220..3718df26 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -162,9 +162,13 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { // Per-side strokes on rectangles/frames. if let Some(sides) = props.sides && matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) - && n.corners.uniform() == Some(0.0) { - return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)); + let c = &n.corners; + return Arc::new(if c.uniform() == Some(0.0) { + figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align) + } else { + figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align) + }); } // Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines. if props.dashes.is_empty() && props.profile().is_none() && props.brush.is_none() && closed && props.align != StrokeAlign::Center { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 55488153..a5a59831 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -129,6 +129,27 @@ fn strokes_align() { assert_eq!(px(&pix, 30, 8)[0], 0xF5); } +#[test] +fn side_strokes_follow_rounded_corners() { + // border-l-4 rounded-r-lg: a straight 4 px bar on the left, nothing on the other edges. + let mut d = figcraft_doc::Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 10.0, 10.0, 120.0, 60.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.fills = vec![Paint::solid(Color::WHITE)]; + n.corners.radii = [0.0, 8.0, 8.0, 0.0]; + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.align = StrokeAlign::Inside; + n.stroke.sides = Some([0.0, 0.0, 0.0, 4.0]); + let pix = render_view(&d, p, &view(150, 80), &Options::default()).unwrap(); + for x in 10..14 { + assert_eq!(px(&pix, x, 40), [0, 0, 0, 255], "x {x}"); + } + for (x, y) in [(14, 40), (129, 40), (126, 12), (126, 67), (70, 10), (70, 69)] { + assert_eq!(px(&pix, x, y), [255, 255, 255, 255], "({x}, {y})"); + } +} + #[test] fn mask_clips_siblings() { let mut d = figcraft_doc::Document::new("t"); diff --git a/crates/svg/src/import/mod.rs b/crates/svg/src/import/mod.rs index fdae7156..3aed363a 100644 --- a/crates/svg/src/import/mod.rs +++ b/crates/svg/src/import/mod.rs @@ -377,15 +377,38 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { return; } path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut network = VectorNetwork::from_path(&path); let rule = if fill.is_some_and(|f| f.rule() == usvg::FillRule::EvenOdd) { WindingRule::EvenOdd } else { WindingRule::NonZero }; - for r in &mut network.regions { - r.winding = rule; + let vector = |path: &BezPath, filled: bool| { + let mut network = VectorNetwork::from_path(path); + for r in &mut network.regions { + r.winding = rule; + } + if !filled { + network.regions.clear(); + } + NodeKind::Vector { network } + }; + let at = Affine::translate((bb.x0, bb.y0)); + // Fill closes every subpath; a stroke only those that close themselves. A filled + // and stroked path with a really open subpath becomes a fill layer and a stroke layer. + let tol = CLOSE_TOLERANCE * bb.width().max(bb.height()); + let (snapped, closed) = (closed_for_fill(&path, tol, false), closed_for_fill(&path, tol, true)); + if let (Some(_), Some(_)) = (fill, stroke) + && snapped != closed + { + let fill_only = + Node::new(cx.doc.alloc_id(), vector(&closed, true), format!("{} fill", layer_name(p.id(), "Vector")), bb.width(), bb.height()); + let stroke_only = Node::new(cx.doc.alloc_id(), vector(&snapped, false), layer_name(p.id(), "Vector"), bb.width(), bb.height()); + let mut layers = [paint_path(cx, p, fill_only, at, world, true, false), paint_path(cx, p, stroke_only, at, world, false, true)]; + if p.paint_order() == usvg::PaintOrder::StrokeAndFill { + layers.reverse(); + } + for n in layers { + cx.add(n, parent); + } + return; } - if fill.is_none() { - network.regions.clear(); - } - (NodeKind::Vector { network }, Affine::translate((bb.x0, bb.y0)), bb.width(), bb.height(), 0.0) + (vector(if fill.is_some() { &closed } else { &path }, fill.is_some()), at, bb.width(), bb.height(), 0.0) } }; let fallback = match kind { @@ -395,12 +418,18 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { _ => "Vector", }; let mut n = Node::new(cx.doc.alloc_id(), kind, layer_name(p.id(), fallback), w, h); - n.transform = transform; n.corners.radii = [radius; 4]; - let target = Target { to_node: transform.inverse() * world, size: (w, h) }; - n.fills = fill.and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect(); + let n = paint_path(cx, p, n, transform, world, true, true); + cx.add(n, parent); +} + +/// Layer `n` of path `p` placed at `transform`, with the path's fill and / or stroke. +fn paint_path(cx: &mut Ctx, p: &usvg::Path, mut n: Node, transform: Affine, world: Affine, with_fill: bool, with_stroke: bool) -> Node { + n.transform = transform; + let target = Target { to_node: transform.inverse() * world, size: (n.width, n.height) }; + n.fills = p.fill().filter(|_| with_fill).and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect(); n.strokes.clear(); - if let Some(s) = stroke { + if let Some(s) = p.stroke().filter(|_| with_stroke) { n.strokes = paint::paint(cx, s.paint(), s.opacity().get(), target).into_iter().collect(); n.stroke = stroke_props(s, length_scale(world)); // A stroke painted under the fill shows only its outer half. @@ -409,7 +438,55 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { n.stroke.weight /= 2.0; } } - cx.add(n, parent); + n +} + +/// How far (as a share of the path's larger side) a subpath may end from its start and still be +/// closed onto it: exporters that round coordinates leave closed outlines this far apart. +const CLOSE_TOLERANCE: f64 = 1e-3; + +/// `path` with the subpaths that end within `tol` of their start closed onto it and, with `all` +/// (SVG fills every subpath as if closed), every other subpath that can enclose an area (two or +/// more segments, or one curve) closed with a straight segment. +fn closed_for_fill(path: &BezPath, tol: f64, all: bool) -> BezPath { + use figcraft_geom::kurbo::PathEl; + let mut out = BezPath::new(); + let mut sub: Vec = Vec::new(); + let flush = |sub: &mut Vec, out: &mut BezPath| { + let start = match sub.first() { + Some(PathEl::MoveTo(p)) => *p, + _ => { + sub.clear(); + return; + } + }; + let open = !matches!(sub.last(), Some(PathEl::ClosePath)); + let segments = sub.len() - 1; + let curve = matches!(sub.last(), Some(PathEl::QuadTo(..) | PathEl::CurveTo(..))); + if open && (segments >= 2 || curve) { + let near = sub.last().and_then(|e| e.end_point()).is_some_and(|end| (end - start).hypot() <= tol); + if near && let Some(last) = sub.last_mut() { + match last { + PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => *p = start, + PathEl::MoveTo(_) | PathEl::ClosePath => {} + } + } + if near || all { + sub.push(PathEl::ClosePath); + } + } + for e in sub.drain(..) { + out.push(e); + } + }; + for e in path.elements() { + if matches!(e, PathEl::MoveTo(_)) { + flush(&mut sub, &mut out); + } + sub.push(*e); + } + flush(&mut sub, &mut out); + out } /// A recognised primitive placed by `world`: its kind, transform, size and corner radius. `None` diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 55176aaf..656307d9 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -79,6 +79,8 @@ struct W<'a> { clip: Option, /// `id` attributes written so far. used_ids: std::collections::BTreeSet, + /// The next `_k` suffix to try per base id, so repeated names don't rescan from `_2`. + next_suffix: std::collections::HashMap, } /// ` clip-path="url(#…)"` for a clip path id (else nothing). @@ -150,8 +152,15 @@ fn blend_style(b: figcraft_color::BlendMode) -> String { } } +/// `s` escaped for XML text and attribute values; characters XML 1.0 forbids (control characters +/// other than tab, newline and carriage return, U+FFFE and U+FFFF) are left out. fn esc(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) + s.chars().filter(|&c| xml_char(c)).collect::().replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// Whether XML 1.0 allows `c` (see [`esc`]). +fn xml_char(c: char) -> bool { + !matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}') } fn hex(c: Color) -> String { @@ -160,8 +169,13 @@ fn hex(c: Color) -> String { impl W<'_> { fn id(&mut self, prefix: &str) -> String { - self.next += 1; - format!("{prefix}{}", self.next) + loop { + self.next += 1; + let id = format!("{prefix}{}", self.next); + if self.used_ids.insert(id.clone()) { + return id; + } + } } /// `fill="..." [fill-opacity]` for a paint on a w×h box; `None` when it paints nothing. @@ -547,9 +561,19 @@ impl W<'_> { /// `name` as an `id` not used yet in this file (spaces as underscores; repeats get `_2`…). fn unique_id(&mut self, name: &str) -> String { - let base: String = name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).collect(); + // As written: what `esc` leaves out isn't part of it. + let base: String = + name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).filter(|&c| xml_char(c)).collect(); let base = if base.is_empty() { "layer".to_string() } else { base }; - let id = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}_{k}") }).find(|i| !self.used_ids.contains(i)).unwrap_or(base); + let next = self.next_suffix.entry(base.clone()).or_insert(1); + let id = loop { + let k = *next; + *next += 1; + let candidate = if k == 1 { base.clone() } else { format!("{base}_{k}") }; + if !self.used_ids.contains(&candidate) { + break candidate; + } + }; self.used_ids.insert(id.clone()); id } @@ -1004,6 +1028,7 @@ pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Resu mask: None, clip: None, used_ids: Default::default(), + next_suffix: Default::default(), }; for id in ids { let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 8dece77f..dedeca3b 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -135,6 +135,54 @@ fn title_case_does_not_restart_words_across_fill_runs_in_live_text() { assert!(svg.contains(">He") && svg.contains(">l") && svg.contains(">lo"), "{svg}"); } +/// Control characters in text (a pasted vertical-tab soft break, U+0001) are not allowed in XML: +/// live-text export must leave them out so the SVG still parses, for plain and per-run spans. +#[test] +fn live_text_export_drops_xml_forbidden_control_characters() { + use figcraft_doc::{StyleRun, TextData}; + figcraft_text::scan_system_fonts(); + for styled in [false, true] { + let mut d = Document::new("t"); + let page = d.first_page().unwrap(); + let mut text = TextData::new("line one\u{b}line two\u{1}"); + text.style.font_size = 24.0; + if styled { + let mut style = text.style.clone(); + style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]); + text.runs.push(StyleRun { start: 5, end: 12, style }); + text.normalize(); + } + let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 600.0, 40.0).unwrap(); + d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + let svg = export_svg(&d, &[id], &SvgOptions { outline_text: false, ..SvgOptions::default() }).unwrap(); + assert!(!svg.contains('\u{b}') && !svg.contains('\u{1}'), "{svg:?}"); + assert!(svg.contains("line") && svg.contains("two"), "{svg}"); + let mut d2 = Document::new("t2"); + let p2 = d2.first_page().unwrap(); + assert!(import_svg(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "x").is_ok(), "{svg:?}"); + } +} + +#[test] +fn side_strokes_on_rounded_corners_export_as_their_area() { + // border-l-4 rounded-r-lg: the stroke path is the 4 px bar (60 px tall), drawn even-odd. + use figcraft_geom::Shape; + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 120.0, 60.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.corners.radii = [0.0, 8.0, 8.0, 0.0]; + n.strokes = vec![Paint::solid(Color::from_hex("FF0000").unwrap())]; + n.stroke.align = figcraft_doc::StrokeAlign::Inside; + n.stroke.sides = Some([0.0, 0.0, 0.0, 4.0]); + let svg = export_svg(&d, &[f], &SvgOptions::default()).unwrap(); + let stroke = svg.split(""##; + import_svg(&mut d, svg.as_bytes(), p, 0.0, 0.0, "x").unwrap(); + let loops = |n: &figcraft_doc::Node| match &n.kind { + NodeKind::Vector { network } => network.regions.iter().map(|r| r.loops.len()).sum::(), + _ => 0, + }; + assert_eq!(d.nodes().map(loops).sum::(), 1); +} diff --git a/crates/svg/tests/corpus.rs b/crates/svg/tests/corpus.rs index 35f6f9f4..24803c40 100644 --- a/crates/svg/tests/corpus.rs +++ b/crates/svg/tests/corpus.rs @@ -243,6 +243,27 @@ fn groups_keep_their_ids_and_transforms_bake_into_shapes() { assert_eq!(layer(&d, f, "d").kind.type_name(), "VECTOR"); } +#[test] +fn fills_close_open_subpaths() { + let (d, f) = imported("open_subpaths"); + let loops = |n: &figcraft_doc::Node| match &n.kind { + NodeKind::Vector { network } => network.regions.iter().map(|r| r.loops.len()).sum::(), + _ => 0, + }; + // One filled layer: all four subpaths are fill loops, the 0.001 gap and the triangle included. + let filled = layer(&d, f, "filled"); + assert_eq!((filled.kind.type_name(), loops(filled), filled.strokes.len()), ("VECTOR", 4, 0)); + // Filled and stroked with an open triangle: a fill layer under the stroke, which stays open. + let (fill, stroke) = (layer(&d, f, "stroked fill"), layer(&d, f, "stroked")); + assert_eq!((loops(fill), fill.fills.len(), fill.strokes.len()), (2, 1, 0)); + assert_eq!((loops(stroke), stroke.fills.len(), stroke.strokes.len()), (0, 0, 1)); + // Only near-closed ends: one layer, closed, stroke and all. + let near = layer(&d, f, "near"); + assert_eq!((loops(near), near.fills.len(), near.strokes.len()), (1, 1, 1)); + let order: Vec<&str> = d.node(f).unwrap().children.iter().filter_map(|c| d.node(*c)).map(|n| n.name.as_str()).collect(); + assert_eq!(order, ["filled", "stroked fill", "stroked", "near", "under", "under fill"], "paint order: fill then stroke, or the stroke first"); +} + #[test] fn basic_shapes_stay_rectangles_ellipses_and_lines() { let (d, f) = imported("primitives"); diff --git a/crates/svg/tests/corpus/open_subpaths.svg b/crates/svg/tests/corpus/open_subpaths.svg new file mode 100644 index 00000000..daed50b7 --- /dev/null +++ b/crates/svg/tests/corpus/open_subpaths.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index 3f5458ea..52284d0b 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -19,6 +19,8 @@ enum Class { Hyphen, /// Everything else (letters, digits, other symbols): no break inside a run of them. Other, + /// The figure space: glues what's on either side, ideographs included. + Glue, } /// Opening brackets and quotes (line-end prohibited). @@ -39,7 +41,8 @@ fn class(c: char) -> Class { return Class::Close; } match c { - ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}' => Class::Space, + ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{2006}' | '\u{2008}'..='\u{200B}' => Class::Space, + '\u{2007}' => Class::Glue, '-' | '/' | '\u{2010}' | '\u{2013}' | '\u{2014}' => Class::Hyphen, '\u{2E80}'..='\u{2FFF}' | '\u{3003}'..='\u{33FF}' @@ -60,8 +63,8 @@ pub(crate) fn break_between(a: char, b: char) -> bool { use Class::*; match (class(a), class(b)) { // Spaces hang at the end of the line before them; kinsoku: no closing mark starts a line, - // no opening mark ends one. - (_, Space | Close) | (Open, _) => false, + // no opening mark ends one; glue holds on both sides. + (_, Space | Close | Glue) | (Open | Glue, _) => false, (Space | Hyphen | Ideo, _) | (_, Ideo) => true, _ => false, } @@ -98,3 +101,16 @@ pub fn grapheme_step(text: &str, index: usize, forward: bool) -> usize { } at.min(index) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn figure_space_is_non_breaking() { + assert!(!break_between('\u{2007}', '0')); + assert!(!break_between('1', '\u{2007}')); + assert!(!break_between('日', '\u{2007}') && !break_between('\u{2007}', '日'), "nor beside ideographs"); + assert!(!is_space('\u{2007}')); + } +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 604f3eee..079a7efe 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -812,7 +812,7 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap bool { matches!(c, '\u{1F1E6}'..='\u{1F1FF}' | '\u{1F300}'..='\u{1FAFF}') } +/// The lower case of the capital sigma at `i`: the final form ς or σ, chosen from the letters +/// around it in the whole text (as lower-casing the whole string would), not from the lone char. +fn lower_sigma(chars: &[char], i: usize) -> char { + // Enough context to step over any realistic run of combining marks next to the sigma. + const CONTEXT: usize = 32; + let before: String = chars.get(i.saturating_sub(CONTEXT)..i).unwrap_or(&[]).iter().collect(); + let after: String = chars.iter().skip(i + 1).take(CONTEXT).collect(); + let lower = format!("{before}\u{3A3}{after}").to_lowercase(); + // Only the sigma's lower case depends on context, so the text before it keeps its length. + lower.get(before.to_lowercase().len()..).and_then(|s| s.chars().next()).unwrap_or('σ') +} + /// One run of chars shaped with a single face and style. struct Segment<'a> { start: usize, @@ -1065,6 +1077,8 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe for u in c.to_uppercase() { mapped.push((u, src, true)); } + } else if style.case == TextCase::Lower && *c == 'Σ' { + mapped.push((lower_sigma(chars, src), src, false)); } else { let mut buf = [0u8; 4]; for u in style.case.apply_after(c.encode_utf8(&mut buf), prev).chars() { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ebf5889c..4475fbbc 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -497,3 +497,25 @@ fn the_caret_after_a_line_break_sits_on_the_next_line() { let l = layout(&TextData::new("ab\n"), None, None); assert_eq!(l.caret(3), (0.0, 1)); } + +#[test] +fn lower_case_writes_final_sigma() { + if !have_fonts() { + return; + } + let gids = |t: &TextData| layout(t, None, None).glyphs.iter().map(|g| (g.gid, g.char_start)).collect::>(); + let want = gids(&TextData::new("οδος ας")); + let Some(face) = layout(&TextData::new("ς"), None, None).glyphs.first().map(|g| g.face.clone()) else { return }; + if face.glyph_for('σ') == face.glyph_for('ς') { + eprintln!("face has no distinct final sigma; skipping"); + return; + } + let mut t = TextData::new("ΟΔΟΣ ΑΣ"); + t.style.case = figcraft_doc::TextCase::Lower; + assert_eq!(gids(&t), want, "one run"); + // The word split across two styled runs: the final form is still chosen from the whole text. + let mut other = t.style.clone(); + other.font_size += 4.0; + t.runs.push(figcraft_doc::StyleRun { start: 3, end: 4, style: other }); + assert_eq!(gids(&t).iter().map(|g| g.0).collect::>(), want.iter().map(|g| g.0).collect::>(), "split runs"); +} diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index adedc983..bc7c948c 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -126,6 +126,7 @@ status = "draft — needs native review" "Archived" = "আর্কাইভ করা হয়েছে" "Arrange" = "সাজান" "Arrow" = "তীর" +"Arrow at the end" = "শেষে তীর" "As typed" = "যেমনটি টাইপ করা হয়েছে" "Asking the server…" = "সার্ভারকে জিজ্ঞাসা করা হচ্ছে..." "Assets" = "অ্যাসেট" @@ -199,12 +200,15 @@ status = "draft — needs native review" "C or ⇧C" = "C বা ⇧C" "CMYK colour" = "CMYK রঙ" "Can view" = "দেখতে পারেন" +"Check" = "টিক" "Checking the fonts installed on this computer…" = "এই কম্পিউটারে ইনস্টল করা ফন্টগুলো চেক করা হচ্ছে..." "Checking the server…" = "সার্ভার চেক করা হচ্ছে..." +"Chevron" = "শেভরন" "Clear from this list" = "এই তালিকা থেকে মুছে ফেলুন" "Code or invite link" = "কোড বা আমন্ত্রণ লিংক" "Color profile: {name}" = "কালার প্রোফাইল: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "রঙ ও টেক্সট স্টাইল, লাইট এবং ডার্ক ভেরিয়েবল, বোতাম, ইনপুট এবং কার্ড" +"Connector" = "সংযোগকারী" "Convert to frame" = "ফ্রেম রূপান্তর করুন" "Convert to section" = "সেকশনে রূপান্তর করুন" "CSS custom properties…" = "CSS কাস্টম প্রপার্টি..." @@ -249,7 +253,6 @@ status = "draft — needs native review" "Close tab" = "ট্যাব বন্ধ করুন" "Close version history" = "সংস্করণ ইতিহাস বন্ধ করুন" "Close when clicking outside" = "বাইরে ক্লিক করলে বন্ধ করুন" -"Close without saving" = "সেভ না করেই বন্ধ করুন" "Code" = "কোড" "Code syntax" = "কোড সিনট্যাক্স" "Collapse" = "সংকুচিত করুন" @@ -378,6 +381,7 @@ status = "draft — needs native review" "Description" = "বিবরণ" "Description (optional)" = "বিবরণ (ঐচ্ছিক)" "Design" = "ডিজাইন" +"Design panel" = "ডিজাইন প্যানেল" "Design system starter" = "ডিজাইন সিস্টেম স্টার্টার" "Desktop" = "ডেস্কটপ" "Destination" = "গন্তব্য" @@ -405,6 +409,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "অনুভূমিক ব্যবধান বিতরণ করুন" "Distribute vertical spacing" = "উল্লম্ব ব্যবধান বিতরণ করুন" "Divider" = "ডিভাইডার" +"Don't Save" = "সংরক্ষণ করবেন না" "Don't truncate" = "ট্রাঙ্কেট করবেন না" "Done" = "সম্পন্ন" "Done editing" = "সম্পাদনা শেষ" @@ -440,6 +445,7 @@ status = "draft — needs native review" "Effect settings" = "ইফেক্ট সেটিংস" "Effect styles" = "ইফেক্ট স্টাইল" "Effects" = "ইফেক্টসমূহ" +"Elbow" = "কোণযুক্ত" "Ellipse" = "উপবৃত্ত" "Ellipses" = "উপবৃত্তসমূহ" "Else" = "অন্যথায় (Else)" @@ -576,6 +582,7 @@ status = "draft — needs native review" "Hanging list" = "হ্যাংগিং তালিকা" "Hanging punctuation" = "হ্যাংগিং বিরামচিহ্ন" "Hard light" = "হার্ড লাইট" +"Heart" = "হৃদয়" "Height" = "উচ্চতা" "Help" = "সহায়তা" "Help and resources" = "সহায়তা ও রিসোর্স" @@ -588,6 +595,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "এই টিপসগুলো লুকান (সহায়তা মেনুর 'শুরু করা' অপশনটি এগুলো আবার দেখাবে)" "Hide when publishing" = "প্রকাশ করার সময় লুকান" "Highlight layers on hover" = "হোভারে লেয়ার হাইলাইট করুন" +"Highlighter" = "হাইলাইটার" "Highlights" = "হাইলাইটস" "Hold Alt" = "Alt চেপে ধরে রাখুন" "Hold Alt+Z" = "Alt+Z চেপে ধরে রাখুন" @@ -754,6 +762,7 @@ status = "draft — needs native review" "Mark as resolved" = "সমাধানকৃত হিসেবে চিহ্নিত করুন" "Mark as unread" = "অপঠিত হিসেবে চিহ্নিত করুন" "Mark as unresolved" = "অমীমাংসিত হিসেবে চিহ্নিত করুন" +"Marker" = "মার্কার" "Mask" = "মাস্ক" "Mass · Stiffness · Damping" = "ভর · দৃঢ়তা · ড্যাম্পিং" "Match (empty: the whole name)" = "মেলবন্ধন (খালি: সম্পূর্ণ নাম)" @@ -813,6 +822,7 @@ status = "draft — needs native review" "Named instance" = "নামকৃত ইনস্ট্যান্স" "Navigate to" = "এতে যান" "Nested instance" = "নেস্টেড ইনস্ট্যান্স" +"New board" = "নতুন বোর্ড" "New design file" = "নতুন ডিজাইন ফাইল" "Newest first" = "সবচেয়ে নতুন প্রথমে" "Next or previous control in the focused region" = "ফোকাসড অঞ্চলের পরবর্তী বা পূর্ববর্তী কন্ট্রোল" @@ -953,6 +963,7 @@ status = "draft — needs native review" "Page" = "পেজ" "Pages" = "পেজসমূহ" "Pages added to the document" = "ডকুমেন্টে পেজ যোগ করা হয়েছে" +"Pink" = "গোলাপি" "Placed on the current page" = "বর্তমান পেজে রাখা হয়েছে" "Pages: {pages}" = "পেজসমূহ: {pages}" "Paint bucket" = "পেইন্ট বালতি" @@ -997,6 +1008,7 @@ status = "draft — needs native review" "Plugins & widgets" = "প্লাগইন ও উইজেট" "Plus darker" = "প্লাস অন্ধকার" "Plus lighter" = "প্লাস হালকা" +"Plus one" = "প্লাস ওয়ান" "Point" = "বিন্দু" "Points" = "বিন্দুসমূহ" "Polygon" = "বহুভুজ" @@ -1045,6 +1057,7 @@ status = "draft — needs native review" "Push" = "পুশ" "Push changes to main component" = "প্রধান কম্পোনেন্টে পরিবর্তন পুশ করুন" "Put text on path" = "পাথে টেক্সট রাখুন" +"Question" = "প্রশ্ন" "R" = "R" "Radial" = "রেডিয়াল" "Radius" = "ব্যাসার্ধ" @@ -1145,6 +1158,7 @@ status = "draft — needs native review" "Rotation jitter" = "ঘূর্ণন জিটার" "Round" = "গোল" "Rounded" = "গোল করা" +"Rounded rectangle" = "গোলাকার আয়তক্ষেত্র" "Row" = "সারি" "Row gap" = "সারির গ্যাপ" "Row sizes" = "সারির আকার" @@ -1220,6 +1234,7 @@ status = "draft — needs native review" "Shape" = "শেপ" "Shape builder" = "শেপ বিল্ডার" "Shape tools" = "শেপ টুলস" +"Shapes" = "আকৃতি" "Share" = "শেয়ার" "Share this file" = "এই ফাইলটি শেয়ার করুন" "Share with a code" = "কোড দিয়ে শেয়ার করুন" @@ -1286,10 +1301,13 @@ status = "draft — needs native review" "Space+drag" = "Space+ড্র্যাগ" "Spacing" = "ব্যবধান" "Spam, stolen work, abuse…" = "স্প্যাম, চুরি করা কাজ, অপব্যবহার..." +"Speech bubble" = "কথার বুদবুদ" "Splay" = "সপ্লে" "Spotlight me" = "আমাকে স্পটলাইট করুন" "Spread" = "স্প্রেড" "Square" = "বর্গক্ষেত্র" +"Stamp" = "স্ট্যাম্প" +"Stamps" = "স্ট্যাম্পগুলি" "Standard" = "স্ট্যান্ডার্ড" "Standard ligatures" = "স্ট্যান্ডার্ড লিগেচার" "Star" = "তারকা" @@ -1305,12 +1323,16 @@ status = "draft — needs native review" "Starting to share…" = "শেয়ার করা শুরু হচ্ছে..." "Status" = "স্ট্যাটাস" "Sticky" = "স্টিকি" +"Sticky note" = "স্টিকি নোট" +"Sticky note colors" = "স্টিকি নোটের রং" +"Sticky notes, shapes and connectors" = "স্টিকি নোট, আকৃতি ও সংযোগকারী" "Stop" = "থামুন" "Stop following {name}" = "{name}-কে অনুসরণ করা বন্ধ করুন" "Stop session" = "সেশন বন্ধ করুন" "Stop sharing" = "শেয়ার করা বন্ধ করুন" "Stop spotlighting" = "স্পটলাইটিং বন্ধ করুন" "Stop {n} {hex}" = "স্টপ {n} {hex}" +"Straight" = "সোজা" "Stretch" = "টেনে বড় করা (Stretch)" "Strikethrough" = "স্ট্রাইকথ্রু" "String" = "স্ট্রিং" @@ -1355,6 +1377,7 @@ status = "draft — needs native review" "Text" = "টেক্সট" "Text align" = "টেক্সট সারিবদ্ধকরণ" "Text content" = "টেক্সট কন্টেন্ট" +"Text size" = "লেখার আকার" "Text style" = "টেক্সট স্টাইল" "Text styles" = "টেক্সট স্টাইলসমূহ" "Texts" = "টেক্সটসমূহ" @@ -1389,6 +1412,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "এই সার্ভারটি সবার জন্য উন্মুক্ত: কোনো কী প্রয়োজন নেই।" "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "এই সার্ভারের কী প্রয়োজন: ঠিকানায় ?key=… যোগ করুন (`figcraft-server key` এটি সার্ভারের মেশিনে প্রিন্ট করে)।" "This server takes files only with its key: see Advanced." = "এই সার্ভার শুধুমাত্র এর কী সহ ফাইল নেয়: উন্নত (Advanced) দেখুন।" +"Thumbs up" = "বুড়ো আঙুল উঁচু" "Tidy up" = "পরিপাটি করুন" "Tile" = "টাইল" "Tile type" = "টাইল টাইপ" @@ -1405,6 +1429,7 @@ status = "draft — needs native review" "Top left radius" = "ওপরের বাম ব্যাসার্ধ" "Top right radius" = "ওপরের ডান ব্যাসার্ধ" "Transform" = "ট্রান্সফর্ম" +"Triangle" = "ত্রিভুজ" "Triangle arrow" = "ত্রিভুজ তীর" "Trigger" = "ট্রিগার" "True" = "সত্য (True)" @@ -1430,7 +1455,7 @@ status = "draft — needs native review" "Unread" = "অপঠিত" "Unresolve" = "অমীমাংসিত করুন" "Unsaved changes" = "সংরক্ষিত হয়নি এমন পরিবর্তন" -"Unsaved changes in {names}. Close the window and lose them?" = "{names}-এ সংরক্ষিত হয়নি এমন পরিবর্তন রয়েছে। উইন্ডোটি বন্ধ করলে কি সেগুলো হারিয়ে যাবে?" +"Unsaved changes in {names}. Save them before closing?" = "{names}-এ সংরক্ষিত হয়নি এমন পরিবর্তন রয়েছে। বন্ধ করার আগে সেগুলো সংরক্ষণ করবেন?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন ({save}), অথবা বাতিল করতে {alt}-ক্লিক করে ট্যাব বন্ধ করুন" "Unsaved changes: save first, or close again with {key} to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন, অথবা বাতিল করতে {key} দিয়ে আবার বন্ধ করুন" "Up" = "উপরে" @@ -1739,3 +1764,23 @@ status = "draft — needs native review" "⌘ click" = "⌘ ক্লিক" "⌘ scroll" = "⌘ স্ক্রল" "⌥ drag" = "⌥ ড্র্যাগ" + + +[commands] +"board.connector" = "সংযোগকারী" +"board.ink" = "মার্কারের টান" +"board.label" = "সংযোগকারীর লেবেল" +"board.nextSticky" = "পরের স্টিকি নোট" +"board.setColor" = "বোর্ডের রং" +"board.setStyle" = "বোর্ডের শৈলী" +"board.shape" = "লেখাসহ আকৃতি" +"board.stamp" = "স্ট্যাম্প" +"board.sticky" = "স্টিকি নোট" +"file.newBoard" = "নতুন বোর্ড" +"tool.connector" = "সংযোগকারী" +"tool.eraser" = "ইরেজার" +"tool.highlighter" = "হাইলাইটার" +"tool.marker" = "মার্কার" +"tool.shape" = "আকৃতি" +"tool.stamp" = "স্ট্যাম্প" +"tool.sticky" = "স্টিকি নোট" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index eea0e8dc..e7a2dec1 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -120,6 +120,7 @@ status = "draft — needs native review" "Archived" = "Archivováno" "Arrange" = "Uspořádat" "Arrow" = "Šipka" +"Arrow at the end" = "Šipka na konci" "As typed" = "Podle zadání" "Asking the server…" = "Dotazování serveru…" "Assets" = "Zdroje" @@ -193,7 +194,9 @@ status = "draft — needs native review" "C or ⇧C" = "C nebo ⇧C" "CMYK colour" = "Barva CMYK" "Can view" = "Může prohlížet" +"Check" = "Fajfka" "Checking the server…" = "Kontrola serveru…" +"Chevron" = "Lomená šipka" "Clear from this list" = "Vymazat z tohoto seznamu" "Code or invite link" = "Kód nebo odkaz s pozvánkou" "Color profile: {name}" = "Barevný profil: {name}" @@ -240,7 +243,6 @@ status = "draft — needs native review" "Close tab" = "Zavřít kartu" "Close version history" = "Zavřít historii verzí" "Close when clicking outside" = "Zavřít při kliknutí mimo" -"Close without saving" = "Zavřít bez uložení" "Code" = "Kód" "Code syntax" = "Syntaxe kódu" "Collapse" = "Sbalit" @@ -282,6 +284,7 @@ status = "draft — needs native review" "Connected" = "Připojeno" "Connected to code: {from}" = "Propojeno s kódem: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Spojení ztraceno, opětovné připojování (pokus {attempt})…" +"Connector" = "Spojnice" "Constrain proportions" = "Zachovat poměr stran" "Constraints" = "Omezení" "Content" = "Obsah" @@ -368,6 +371,7 @@ status = "draft — needs native review" "Description" = "Popis" "Description (optional)" = "Popis (nepovinný)" "Design" = "Návrh" +"Design panel" = "Panel návrhu" "Design system starter" = "Základ design systému" "Desktop" = "Počítač" "Destination" = "Cíl" @@ -395,6 +399,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Rozmístit vodorovné rozestupy" "Distribute vertical spacing" = "Rozmístit svislé rozestupy" "Divider" = "Oddělovač" +"Don't Save" = "Neukládat" "Don't truncate" = "Nezkracovat" "Done" = "Hotovo" "Done editing" = "Dokončit úpravy" @@ -427,6 +432,7 @@ status = "draft — needs native review" "Effect settings" = "Nastavení efektu" "Effect styles" = "Styly efektů" "Effects" = "Efekty" +"Elbow" = "Lomená" "Ellipse" = "Elipsa" "Ellipses" = "Elipsy" "Else" = "Jinak" @@ -562,6 +568,7 @@ status = "draft — needs native review" "Hanging list" = "Předsazený seznam" "Hanging punctuation" = "Předsazená interpunkce" "Hard light" = "Tvrdé světlo" +"Heart" = "Srdce" "Height" = "Výška" "Help" = "Nápověda" "Help and resources" = "Nápověda a zdroje" @@ -574,6 +581,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Skrýt tyto tipy (znovu je zobrazíte volbou Začínáme v nabídce Nápověda)" "Hide when publishing" = "Skrýt při publikování" "Highlight layers on hover" = "Zvýraznit vrstvy pod ukazatelem" +"Highlighter" = "Zvýrazňovač" "Highlights" = "Světla" "Hold Alt" = "Podržte Alt" "Hold Alt+Z" = "Podržte Alt+Z" @@ -739,6 +747,7 @@ status = "draft — needs native review" "Mark as resolved" = "Označit jako vyřešené" "Mark as unread" = "Označit jako nepřečtené" "Mark as unresolved" = "Označit jako nevyřešené" +"Marker" = "Fix" "Mask" = "Maska" "Mass · Stiffness · Damping" = "Hmotnost · Tuhost · Tlumení" "Match (empty: the whole name)" = "Hledaný text (prázdné: celý název)" @@ -797,6 +806,7 @@ status = "draft — needs native review" "Named instance" = "Pojmenovaná instance" "Navigate to" = "Přejít na" "Nested instance" = "Vnořená instance" +"New board" = "Nová tabule" "New design file" = "Nový soubor návrhu" "Newest first" = "Od nejnovějších" "Next frame" = "Další rámec" @@ -898,6 +908,7 @@ status = "draft — needs native review" "Open branch" = "Otevřít větev" "Open file" = "Otevřít soubor" "Open files" = "Otevřít soubory" +"Open presentations" = "Otevřené prezentace" "Open in focus view" = "Otevřít v zaměřeném pohledu" "Open in new window" = "Otevřít v novém okně" "Open invite links in FigCraft" = "Otevírat odkazy s pozvánkou v aplikaci FigCraft" @@ -933,6 +944,7 @@ status = "draft — needs native review" "Page deleted" = "Stránka smazána" "Pages" = "Stránky" "Pages added to the document" = "Stránky přidány do dokumentu" +"Pink" = "Růžová" "Placed on the current page" = "Umístěno na aktuální stránku" "Pages: {pages}" = "Stránky: {pages}" "Paint bucket" = "Plechovka barvy" @@ -975,6 +987,7 @@ status = "draft — needs native review" "Plugins" = "Pluginy" "Plus darker" = "Sčítání se ztmavením" "Plus lighter" = "Sčítání se zesvětlením" +"Plus one" = "Plus jedna" "Point" = "Bod" "Points" = "Body" "Polygon" = "Mnohoúhelník" @@ -1023,6 +1036,7 @@ status = "draft — needs native review" "Push" = "Vytlačení" "Push changes to main component" = "Přenést změny do hlavní komponenty" "Put text on path" = "Umístit text na cestu" +"Question" = "Otázka" "R" = "R" "Radial" = "Kruhový" "Radius" = "Poloměr" @@ -1122,6 +1136,7 @@ status = "draft — needs native review" "Rotation jitter" = "Náhodná odchylka natočení" "Round" = "Kulatý" "Rounded" = "Zaoblený" +"Rounded rectangle" = "Zaoblený obdélník" "Row" = "Řádek" "Row gap" = "Mezera mezi řádky" "Row sizes" = "Velikosti řádků" @@ -1194,6 +1209,7 @@ status = "draft — needs native review" "Shape" = "Tvar" "Shape builder" = "Tvorba tvarů" "Shape tools" = "Nástroje tvarů" +"Shapes" = "Tvary" "Share" = "Sdílet" "Share this file" = "Sdílet tento soubor" "Share with a code" = "Sdílet pomocí kódu" @@ -1260,10 +1276,13 @@ status = "draft — needs native review" "Space+drag" = "Mezerník+tažení" "Spacing" = "Rozestupy" "Spam, stolen work, abuse…" = "Spam, odcizená práce, zneužití…" +"Speech bubble" = "Bublina" "Splay" = "Rozevření" "Spotlight me" = "Sdílet můj pohled" "Spread" = "Rozšíření" "Square" = "Čtverec" +"Stamp" = "Razítko" +"Stamps" = "Razítka" "Standard" = "Standardní" "Standard ligatures" = "Standardní ligatury" "Star" = "Hvězda" @@ -1279,12 +1298,16 @@ status = "draft — needs native review" "Starting to share…" = "Zahajování sdílení…" "Status" = "Stav" "Sticky" = "Přichycené" +"Sticky note" = "Lepicí poznámka" +"Sticky note colors" = "Barvy lepicích poznámek" +"Sticky notes, shapes and connectors" = "Lepicí poznámky, tvary a spojnice" "Stop" = "Zastavit" "Stop following {name}" = "Přestat sledovat: {name}" "Stop session" = "Ukončit relaci" "Stop sharing" = "Zastavit sdílení" "Stop spotlighting" = "Ukončit sdílení pohledu" "Stop {n} {hex}" = "Zarážka {n} {hex}" +"Straight" = "Rovná" "Stretch" = "Roztáhnout" "Strikethrough" = "Přeškrtnutí" "String" = "Textový řetězec" @@ -1329,6 +1352,7 @@ status = "draft — needs native review" "Text" = "Text" "Text align" = "Zarovnání textu" "Text content" = "Textový obsah" +"Text size" = "Velikost textu" "Text style" = "Textový styl" "Text styles" = "Textové styly" "Texts" = "Texty" @@ -1359,6 +1383,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Tento server je veřejný: klíč není potřeba." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Tento server vyžaduje klíč: přidejte do adresy ?key=… (příkaz `figcraft-server key` ho vypíše na počítači serveru)." "This server takes files only with its key: see Advanced." = "Tento server přijímá soubory pouze s klíčem: viz Pokročilé." +"Thumbs up" = "Palec nahoru" "Tidy up" = "Uspořádat" "Tile" = "Dlaždice" "Tile type" = "Typ dlaždic" @@ -1375,6 +1400,7 @@ status = "draft — needs native review" "Top left radius" = "Poloměr levého horního rohu" "Top right radius" = "Poloměr pravého horního rohu" "Transform" = "Transformace" +"Triangle" = "Trojúhelník" "Triangle arrow" = "Trojúhelníková šipka" "Trigger" = "Spouštěč" "True" = "Pravda" @@ -1399,7 +1425,7 @@ status = "draft — needs native review" "Unread" = "Nepřečtené" "Unresolve" = "Znovu otevřít" "Unsaved changes" = "Neuložené změny" -"Unsaved changes in {names}. Close the window and lose them?" = "Neuložené změny v {names}. Zavřít okno a zahodit je?" +"Unsaved changes in {names}. Save them before closing?" = "Neuložené změny v {names}. Uložit je před zavřením?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Neuložené změny: nejprve uložte ({save}) nebo zavřete kartu kliknutím s {alt} a zahoďte je" "Unsaved changes: save first, or close again with {key} to discard" = "Neuložené změny: nejprve uložte nebo zavřete znovu s {key} a zahoďte je" "Up" = "Nahoru" @@ -1729,6 +1755,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Zaokrouhlit na pixely" "arrange.spacing" = "Rozestupy" "arrange.tidy" = "Uspořádat" +"board.connector" = "Spojnice" +"board.ink" = "Tah fixem" +"board.label" = "Popisek spojnice" +"board.nextSticky" = "Další lepicí poznámka" +"board.setColor" = "Barva na tabuli" +"board.setStyle" = "Styl na tabuli" +"board.shape" = "Tvar s textem" +"board.stamp" = "Razítko" +"board.sticky" = "Lepicí poznámka" "bool.exclude" = "Vyloučit výběr" "bool.flatten" = "Sloučit booleovskou skupinu do vektoru" "bool.intersect" = "Průnik výběru" @@ -1854,6 +1889,7 @@ status = "draft — needs native review" "file.import" = "Importovat" "file.moveTab" = "Přesunout kartu" "file.new" = "Nový soubor návrhu" +"file.newBoard" = "Nová tabule" "file.newFromTemplate" = "Nový ze šablony" "file.open" = "Otevřít" "file.openRecent" = "Otevřít nedávné" @@ -2120,10 +2156,14 @@ status = "draft — needs native review" "text.weightUp" = "Zvětšit tloušťku písma" "tool.arrow" = "Šipka" "tool.comment" = "Komentář" +"tool.connector" = "Spojnice" "tool.ellipse" = "Elipsa" +"tool.eraser" = "Guma" "tool.frame" = "Rámec" "tool.hand" = "Ruka" +"tool.highlighter" = "Zvýrazňovač" "tool.line" = "Čára" +"tool.marker" = "Fix" "tool.move" = "Přesunout" "tool.pen" = "Pero" "tool.pencil" = "Tužka" @@ -2135,9 +2175,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Kotva měřítka" "tool.section" = "Sekce" "tool.set" = "Vybrat nástroj" +"tool.shape" = "Tvary" "tool.shapeBuilder" = "Tvorba tvarů" "tool.slice" = "Výřez" +"tool.stamp" = "Razítko" "tool.star" = "Hvězda" +"tool.sticky" = "Lepicí poznámka" "tool.text" = "Text" "transform.move" = "Přesunout" "transform.moveTo" = "Přesunout na" @@ -2199,11 +2242,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Nástroj úprav vektoru" "vector.edit.transform" = "Transformovat body" "vector.edit.widthPoint" = "Bod šířky" +"vector.fillRule" = "Nastavit pravidlo výplně" "vector.flatten" = "Sloučit do vektoru" "vector.join" = "Spojit výběr" "vector.offset" = "Odsadit cestu" "vector.outlineStroke" = "Převést tah na obrysy" "vector.reverse" = "Obrátit směr cesty" +"vector.reverseLoop" = "Obrátit smyčku" "vector.simplify" = "Zjednodušit vektor" "vector.smoothJoin" = "Hladce spojit výběr" "version.autosave" = "Automaticky uložit verzi" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index f61464b8..816fecf1 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -118,6 +118,7 @@ status = "draft — needs native review" "Archived" = "Archiviert" "Arrange" = "Anordnen" "Arrow" = "Pfeil" +"Arrow at the end" = "Pfeil am Ende" "As typed" = "Wie eingegeben" "Asking the server…" = "Server wird gefragt…" "Assets" = "Assets" @@ -189,7 +190,9 @@ status = "draft — needs native review" "C or ⇧C" = "C oder ⇧C" "CMYK colour" = "CMYK-Farbe" "Can view" = "Kann ansehen" +"Check" = "Häkchen" "Checking the server…" = "Server wird geprüft…" +"Chevron" = "Chevron" "Clear from this list" = "Aus dieser Liste entfernen" "Code or invite link" = "Code oder Einladungslink" "Color profile: {name}" = "Farbprofil: {name}" @@ -235,7 +238,6 @@ status = "draft — needs native review" "Close tab" = "Tab schließen" "Close version history" = "Versionsverlauf schließen" "Close when clicking outside" = "Bei Klick außerhalb schließen" -"Close without saving" = "Ohne Speichern schließen" "Code" = "Code" "Code syntax" = "Code-Syntax" "Collapse" = "Einklappen" @@ -262,6 +264,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Unternehmen" "Compare changes" = "Änderungen vergleichen" +"Compare with" = "Vergleichen mit" "Compatibility notes" = "Hinweise zur Kompatibilität" "Completed" = "Abgeschlossen" "Component" = "Komponente" @@ -277,6 +280,7 @@ status = "draft — needs native review" "Connected" = "Verbunden" "Connected to code: {from}" = "Mit Code verbunden: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Verbindung verloren, neuer Versuch ({attempt})…" +"Connector" = "Verbinder" "Constrain proportions" = "Proportionen beibehalten" "Constraints" = "Constraints" "Content" = "Inhalt" @@ -361,6 +365,7 @@ status = "draft — needs native review" "Description" = "Beschreibung" "Description (optional)" = "Beschreibung (optional)" "Design" = "Design" +"Design panel" = "Design-Bereich" "Desktop" = "Desktop" "Destination" = "Ziel" "Detach alias" = "Alias lösen" @@ -371,6 +376,7 @@ status = "draft — needs native review" "Detach variable" = "Variable lösen" "Details" = "Details" "Dev Mode" = "Entwicklermodus" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Der Entwicklermodus prüft nur: Wechseln Sie zu Design (Shift+D), um zu bearbeiten" "Dev resources" = "Entwicklerressourcen" "Development" = "Entwicklung" "Device" = "Gerät" @@ -387,6 +393,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Horizontale Abstände verteilen" "Distribute vertical spacing" = "Vertikale Abstände verteilen" "Divider" = "Trennlinie" +"Don't Save" = "Nicht speichern" "Don't truncate" = "Nicht kürzen" "Done" = "Fertig" "Done editing" = "Bearbeitung beenden" @@ -420,6 +427,7 @@ status = "draft — needs native review" "Effect settings" = "Effekteinstellungen" "Effect styles" = "Effektstile" "Effects" = "Effekte" +"Elbow" = "Gewinkelt" "Ellipse" = "Ellipse" "Ellipses" = "Ellipsen" "Else" = "Sonst" @@ -550,6 +558,7 @@ status = "draft — needs native review" "Hanging list" = "Hängende Liste" "Hanging punctuation" = "Hängende Interpunktion" "Hard light" = "Hartes Licht" +"Heart" = "Herz" "Height" = "Höhe" "Help" = "Hilfe" "Help and resources" = "Hilfe und Ressourcen" @@ -560,6 +569,7 @@ status = "draft — needs native review" "Hide from publishing" = "Nicht veröffentlichen" "Hide section contents" = "Abschnittsinhalt ausblenden" "Highlight layers on hover" = "Ebenen beim Überfahren hervorheben" +"Highlighter" = "Textmarker" "Highlights" = "Lichter" "Hold Alt" = "Alt gedrückt halten" "Hold Alt+Z" = "Alt+Z gedrückt halten" @@ -715,6 +725,7 @@ status = "draft — needs native review" "Mark as resolved" = "Als erledigt markieren" "Mark as unread" = "Als ungelesen markieren" "Mark as unresolved" = "Als nicht erledigt markieren" +"Marker" = "Marker" "Mask" = "Maske" "Mass · Stiffness · Damping" = "Masse · Steifigkeit · Dämpfung" "Match (empty: the whole name)" = "Übereinstimmung (leer: der ganze Name)" @@ -771,6 +782,7 @@ status = "draft — needs native review" "Named instance" = "Benannte Instanz" "Navigate to" = "Navigieren zu" "Nested instance" = "Verschachtelte Instanz" +"New board" = "Neues Whiteboard" "New design file" = "Neue Designdatei" "Next frame" = "Nächster Rahmen" "Next or previous control in the focused region" = "Nächstes oder vorheriges Steuerelement im fokussierten Bereich" @@ -833,6 +845,7 @@ status = "draft — needs native review" "Not read yet" = "Noch nicht gelesen" "Not shared" = "Nicht geteilt" "Note {n}" = "Notiz {n}" +"Nothing changed since this version." = "Seit dieser Version hat sich nichts geändert." "Nothing changed since this was marked ready for dev." = "Seit der Markierung als bereit für die Entwicklung hat sich nichts geändert." "Nothing here yet. Set a frame's status to list it." = "Noch nichts hier. Legen Sie den Status eines Rahmens fest, um ihn aufzulisten." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Noch nichts online. Stellen Sie eine Datei über „Teilen“ online, dann erscheint sie hier." @@ -866,6 +879,7 @@ status = "draft — needs native review" "Open branch" = "Branch öffnen" "Open file" = "Datei öffnen" "Open files" = "Geöffnete Dateien" +"Open presentations" = "Geöffnete Präsentationen" "Open in focus view" = "In Fokusansicht öffnen" "Open in new window" = "In neuem Fenster öffnen" "Open invite links in FigCraft" = "Einladungslinks in FigCraft öffnen" @@ -902,6 +916,7 @@ status = "draft — needs native review" "Page deleted" = "Seite gelöscht" "Pages" = "Seiten" "Pages added to the document" = "Seiten zum Dokument hinzugefügt" +"Pink" = "Rosa" "Placed on the current page" = "Auf der aktuellen Seite platziert" "Pages: {pages}" = "Seiten: {pages}" "Paint bucket" = "Farbeimer" @@ -943,6 +958,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins laufen in der Sandbox von FigCraft, ohne Dateien, Netzwerk oder Uhr. Installieren Sie nur Plugins, denen Sie vertrauen." "Plus darker" = "Plus dunkler" "Plus lighter" = "Plus heller" +"Plus one" = "Plus eins" "Point" = "Punkt" "Points" = "Punkte" "Polygon" = "Polygon" @@ -989,6 +1005,7 @@ status = "draft — needs native review" "Push" = "Schieben" "Push changes to main component" = "Änderungen an Hauptkomponente übertragen" "Put text on path" = "Text auf Pfad setzen" +"Question" = "Frage" "R" = "R" "Radial" = "Radial" "Radius" = "Radius" @@ -1033,6 +1050,7 @@ status = "draft — needs native review" "Removed" = "Entfernt" "Removed from the server" = "Vom Server entfernt" "Removed since it was marked" = "Seit der Markierung entfernt" +"Removed since this version" = "Seit dieser Version entfernt" "Rename" = "Umbenennen" "Rename duplicated layers" = "Duplizierte Ebenen umbenennen" "Rename folder" = "Ordner umbenennen" @@ -1088,6 +1106,7 @@ status = "draft — needs native review" "Rotation jitter" = "Drehungsschwankung" "Round" = "Rund" "Rounded" = "Abgerundet" +"Rounded rectangle" = "Abgerundetes Rechteck" "Row" = "Zeile" "Row gap" = "Zeilenabstand" "Row sizes" = "Zeilengrößen" @@ -1157,6 +1176,7 @@ status = "draft — needs native review" "Shape" = "Form" "Shape builder" = "Formerstellung" "Shape tools" = "Formwerkzeuge" +"Shapes" = "Formen" "Share" = "Teilen" "Share this file" = "Diese Datei teilen" "Share: live session, export" = "Teilen: Live-Sitzung, Export" @@ -1214,10 +1234,13 @@ status = "draft — needs native review" "Space+drag" = "Leertaste+Ziehen" "Spacing" = "Abstand" "Spam, stolen work, abuse…" = "Spam, gestohlene Arbeit, Missbrauch…" +"Speech bubble" = "Sprechblase" "Splay" = "Spreizung" "Spotlight me" = "Spotlight auf mich" "Spread" = "Ausbreitung" "Square" = "Quadrat" +"Stamp" = "Stempel" +"Stamps" = "Stempel" "Standard" = "Standard" "Standard ligatures" = "Standardligaturen" "Star" = "Stern" @@ -1232,12 +1255,16 @@ status = "draft — needs native review" "Starting to share…" = "Teilen wird gestartet…" "Status" = "Status" "Sticky" = "Fixiert" +"Sticky note" = "Haftnotiz" +"Sticky note colors" = "Haftnotizfarben" +"Sticky notes, shapes and connectors" = "Haftnotizen, Formen und Verbinder" "Stop" = "Beenden" "Stop following {name}" = "{name} nicht mehr folgen" "Stop session" = "Sitzung beenden" "Stop sharing" = "Teilen beenden" "Stop spotlighting" = "Spotlight beenden" "Stop {n} {hex}" = "Farbunterbrechung {n} {hex}" +"Straight" = "Gerade" "Stretch" = "Strecken" "Strikethrough" = "Durchgestrichen" "String" = "Zeichenfolge" @@ -1281,6 +1308,7 @@ status = "draft — needs native review" "Text" = "Text" "Text align" = "Textausrichtung" "Text content" = "Textinhalt" +"Text size" = "Textgröße" "Text style" = "Textstil" "Text styles" = "Textstile" "Texts" = "Texte" @@ -1314,6 +1342,9 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Dieser Server ist öffentlich zugänglich: kein Schlüssel nötig." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Dieser Server braucht seinen Schlüssel: Fügen Sie der Adresse ?key=… hinzu (`figcraft-server key` gibt ihn auf dem Rechner des Servers aus)." "This server takes files only with its key: see Advanced." = "Dieser Server nimmt Dateien nur mit seinem Schlüssel an: siehe Erweitert." +"Thumbs up" = "Daumen hoch" +"This version can't be read." = "Diese Version kann nicht gelesen werden." +"This version doesn't have it: all of it is new." = "Diese Version enthält es nicht: alles daran ist neu." "Tidy up" = "Aufräumen" "Tile" = "Kachel" "Tile type" = "Kacheltyp" @@ -1330,6 +1361,7 @@ status = "draft — needs native review" "Top left radius" = "Radius oben links" "Top right radius" = "Radius oben rechts" "Transform" = "Transformieren" +"Triangle" = "Dreieck" "Triangle arrow" = "Dreieckspfeil" "Trigger" = "Auslöser" "True" = "Wahr" @@ -1352,7 +1384,7 @@ status = "draft — needs native review" "Unread" = "Ungelesen" "Unresolve" = "Wieder öffnen" "Unsaved changes" = "Nicht gespeicherte Änderungen" -"Unsaved changes in {names}. Close the window and lose them?" = "Nicht gespeicherte Änderungen in {names}. Fenster schließen und Änderungen verwerfen?" +"Unsaved changes in {names}. Save them before closing?" = "Nicht gespeicherte Änderungen in {names}. Vor dem Schließen speichern?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Nicht gespeicherte Änderungen: Speichern Sie zuerst ({save}) oder schließen Sie den Tab mit {alt}+Klick, um sie zu verwerfen" "Unsaved changes: save first, or close again with {key} to discard" = "Nicht gespeicherte Änderungen: Speichern Sie zuerst oder schließen Sie erneut mit {key}, um sie zu verwerfen" "Up" = "Nach oben" @@ -1415,10 +1447,12 @@ status = "draft — needs native review" "WebSocket URL copied" = "WebSocket-URL kopiert" "Wedge" = "Keil" "Weight" = "Stärke" +"What changed since a saved version" = "Was sich seit einer gespeicherten Version geändert hat" "What changed since it was marked ready for dev" = "Was sich seit der Markierung als bereit für die Entwicklung geändert hat" "What is wrong with this file? The server's operator gets your report." = "Was stimmt mit dieser Datei nicht? Der Betreiber des Servers erhält Ihre Meldung." "What it's for" = "Wofür sie gedacht ist" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Der Ausgangsstand dieser Änderung ist unbekannt: Sie erfolgte, bevor FigCraft Kopien von Rahmen mit dem Status „Bereit für Entwicklung“ aufbewahrte. Erledigen Sie die Änderung (oder markieren Sie den Rahmen erneut als bereit), um ab jetzt zu vergleichen." +"When marked ready" = "Beim Markieren als bereit" "While hovering" = "Beim Überfahren" "While pressing" = "Beim Drücken" "White" = "Weiß" @@ -1677,6 +1711,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Auf Pixel runden" "arrange.spacing" = "Abstand" "arrange.tidy" = "Aufräumen" +"board.connector" = "Verbinder" +"board.ink" = "Markerstrich" +"board.label" = "Verbinderbeschriftung" +"board.nextSticky" = "Nächste Haftnotiz" +"board.setColor" = "Whiteboard-Farbe" +"board.setStyle" = "Whiteboard-Stil" +"board.shape" = "Form mit Text" +"board.stamp" = "Stempel" +"board.sticky" = "Haftnotiz" "bool.exclude" = "Auswahl ausschließen" "bool.flatten" = "Boolesche Gruppe zusammenfügen" "bool.intersect" = "Schnittmenge der Auswahl" @@ -1801,6 +1844,7 @@ status = "draft — needs native review" "file.import" = "Importieren" "file.moveTab" = "Tab verschieben" "file.new" = "Neue Designdatei" +"file.newBoard" = "Neues Whiteboard" "file.open" = "Öffnen" "file.openRecent" = "Zuletzt verwendete öffnen" "file.placeImage" = "Bild platzieren" @@ -2064,10 +2108,14 @@ status = "draft — needs native review" "text.weightUp" = "Schriftstärke erhöhen" "tool.arrow" = "Pfeil" "tool.comment" = "Kommentar" +"tool.connector" = "Verbinder" "tool.ellipse" = "Ellipse" +"tool.eraser" = "Radiergummi" "tool.frame" = "Rahmen" "tool.hand" = "Hand" +"tool.highlighter" = "Textmarker" "tool.line" = "Linie" +"tool.marker" = "Marker" "tool.move" = "Verschieben" "tool.pen" = "Zeichenstift" "tool.pencil" = "Bleistift" @@ -2079,9 +2127,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Skalierungsanker" "tool.section" = "Abschnitt" "tool.set" = "Werkzeug wählen" +"tool.shape" = "Formen" "tool.shapeBuilder" = "Formerstellung" "tool.slice" = "Slice" +"tool.stamp" = "Stempel" "tool.star" = "Stern" +"tool.sticky" = "Haftnotiz" "tool.text" = "Text" "transform.move" = "Verschieben" "transform.moveTo" = "Verschieben nach" @@ -2143,11 +2194,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Vektorbearbeitungswerkzeug" "vector.edit.transform" = "Punkte transformieren" "vector.edit.widthPoint" = "Breitenpunkt" +"vector.fillRule" = "Füllregel festlegen" "vector.flatten" = "Zusammenfügen" "vector.join" = "Auswahl verbinden" "vector.offset" = "Pfad versetzen" "vector.outlineStroke" = "Kontur in Pfad umwandeln" "vector.reverse" = "Pfadrichtung umkehren" +"vector.reverseLoop" = "Schleife umdrehen" "vector.simplify" = "Vektor vereinfachen" "vector.smoothJoin" = "Auswahl glatt verbinden" "version.autosave" = "Version automatisch speichern" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..83cfcbe1 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -125,6 +125,7 @@ name = "English" "Archived" = "Archived" "Arrange" = "Arrange" "Arrow" = "Arrow" +"Arrow at the end" = "Arrow at the end" "As typed" = "As typed" "Asking the server…" = "Asking the server…" "Assets" = "Assets" @@ -199,12 +200,15 @@ name = "English" "C or ⇧C" = "C or ⇧C" "CMYK colour" = "CMYK colour" "Can view" = "Can view" +"Check" = "Check" "Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…" "Checking the server…" = "Checking the server…" +"Chevron" = "Chevron" "Clear from this list" = "Clear from this list" "Code or invite link" = "Code or invite link" "Color profile: {name}" = "Color profile: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Colour and text styles, Light and Dark variables, buttons, inputs and cards" +"Connector" = "Connector" "Convert to frame" = "Convert to frame" "Convert to section" = "Convert to section" "CSS custom properties…" = "CSS custom properties…" @@ -249,7 +253,6 @@ name = "English" "Close tab" = "Close tab" "Close version history" = "Close version history" "Close when clicking outside" = "Close when clicking outside" -"Close without saving" = "Close without saving" "Code" = "Code" "Code syntax" = "Code syntax" "Collapse" = "Collapse" @@ -276,6 +279,7 @@ name = "English" "Commits" = "Commits" "Company" = "Company" "Compare changes" = "Compare changes" +"Compare with" = "Compare with" "Compatibility notes" = "Compatibility notes" "Completed" = "Completed" "Component" = "Component" @@ -379,6 +383,7 @@ name = "English" "Description" = "Description" "Description (optional)" = "Description (optional)" "Design" = "Design" +"Design panel" = "Design panel" "Design system starter" = "Design system starter" "Desktop" = "Desktop" "Destination" = "Destination" @@ -390,6 +395,7 @@ name = "English" "Detach variable" = "Detach variable" "Details" = "Details" "Dev Mode" = "Dev Mode" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Dev Mode only inspects: switch to Design (Shift+D) to edit" "Dev resources" = "Dev resources" "Development" = "Development" "Device" = "Device" @@ -406,6 +412,7 @@ name = "English" "Distribute horizontal spacing" = "Distribute horizontal spacing" "Distribute vertical spacing" = "Distribute vertical spacing" "Divider" = "Divider" +"Don't Save" = "Don't Save" "Don't truncate" = "Don't truncate" "Done" = "Done" "Done editing" = "Done editing" @@ -441,6 +448,7 @@ name = "English" "Effect settings" = "Effect settings" "Effect styles" = "Effect styles" "Effects" = "Effects" +"Elbow" = "Elbow" "Ellipse" = "Ellipse" "Ellipses" = "Ellipses" "Else" = "Else" @@ -577,6 +585,7 @@ name = "English" "Hanging list" = "Hanging list" "Hanging punctuation" = "Hanging punctuation" "Hard light" = "Hard light" +"Heart" = "Heart" "Height" = "Height" "Help" = "Help" "Help and resources" = "Help and resources" @@ -589,6 +598,7 @@ name = "English" "Hide these tips (Getting started in the Help menu shows them again)" = "Hide these tips (Getting started in the Help menu shows them again)" "Hide when publishing" = "Hide when publishing" "Highlight layers on hover" = "Highlight layers on hover" +"Highlighter" = "Highlighter" "Highlights" = "Highlights" "Hold Alt" = "Hold Alt" "Hold Alt+Z" = "Hold Alt+Z" @@ -758,6 +768,7 @@ name = "English" "Mark as resolved" = "Mark as resolved" "Mark as unread" = "Mark as unread" "Mark as unresolved" = "Mark as unresolved" +"Marker" = "Marker" "Mask" = "Mask" "Mass · Stiffness · Damping" = "Mass · Stiffness · Damping" "Match (empty: the whole name)" = "Match (empty: the whole name)" @@ -817,6 +828,7 @@ name = "English" "Named instance" = "Named instance" "Navigate to" = "Navigate to" "Nested instance" = "Nested instance" +"New board" = "New board" "New design file" = "New design file" "Newest first" = "Newest first" "Next or previous control in the focused region" = "Next or previous control in the focused region" @@ -883,6 +895,7 @@ name = "English" "Not read yet" = "Not read yet" "Not shared" = "Not shared" "Note {n}" = "Note {n}" +"Nothing changed since this version." = "Nothing changed since this version." "Nothing changed since this was marked ready for dev." = "Nothing changed since this was marked ready for dev." "Nothing here yet. Set a frame's status to list it." = "Nothing here yet. Set a frame's status to list it." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Nothing online yet. Use Share on a file to put it online and it is listed here." @@ -920,6 +933,7 @@ name = "English" "Open branch" = "Open branch" "Open file" = "Open file" "Open files" = "Open files" +"Open presentations" = "Open presentations" "Open in focus view" = "Open in focus view" "Open in new window" = "Open in new window" "Open invite links in FigCraft" = "Open invite links in FigCraft" @@ -958,6 +972,7 @@ name = "English" "Page deleted" = "Page deleted" "Pages" = "Pages" "Pages added to the document" = "Pages added to the document" +"Pink" = "Pink" "Placed on the current page" = "Placed on the current page" "Pages: {pages}" = "Pages: {pages}" "Paint bucket" = "Paint bucket" @@ -1002,6 +1017,7 @@ name = "English" "Plugins & widgets" = "Plugins & widgets" "Plus darker" = "Plus darker" "Plus lighter" = "Plus lighter" +"Plus one" = "Plus one" "Point" = "Point" "Points" = "Points" "Polygon" = "Polygon" @@ -1050,6 +1066,7 @@ name = "English" "Push" = "Push" "Push changes to main component" = "Push changes to main component" "Put text on path" = "Put text on path" +"Question" = "Question" "R" = "R" "Radial" = "Radial" "Radius" = "Radius" @@ -1096,6 +1113,7 @@ name = "English" "Removed" = "Removed" "Removed from the server" = "Removed from the server" "Removed since it was marked" = "Removed since it was marked" +"Removed since this version" = "Removed since this version" "Rename" = "Rename" "Rename duplicated layers" = "Rename duplicated layers" "Rename folder" = "Rename folder" @@ -1153,6 +1171,7 @@ name = "English" "Rotation jitter" = "Rotation jitter" "Round" = "Round" "Rounded" = "Rounded" +"Rounded rectangle" = "Rounded rectangle" "Row" = "Row" "Row gap" = "Row gap" "Row sizes" = "Row sizes" @@ -1229,6 +1248,7 @@ name = "English" "Shape" = "Shape" "Shape builder" = "Shape builder" "Shape tools" = "Shape tools" +"Shapes" = "Shapes" "Share" = "Share" "Share this file" = "Share this file" "Share with a code" = "Share with a code" @@ -1295,10 +1315,13 @@ name = "English" "Space+drag" = "Space+drag" "Spacing" = "Spacing" "Spam, stolen work, abuse…" = "Spam, stolen work, abuse…" +"Speech bubble" = "Speech bubble" "Splay" = "Splay" "Spotlight me" = "Spotlight me" "Spread" = "Spread" "Square" = "Square" +"Stamp" = "Stamp" +"Stamps" = "Stamps" "Standard" = "Standard" "Standard ligatures" = "Standard ligatures" "Star" = "Star" @@ -1315,12 +1338,16 @@ name = "English" "Starting to share…" = "Starting to share…" "Status" = "Status" "Sticky" = "Sticky" +"Sticky note" = "Sticky note" +"Sticky note colors" = "Sticky note colors" +"Sticky notes, shapes and connectors" = "Sticky notes, shapes and connectors" "Stop" = "Stop" "Stop following {name}" = "Stop following {name}" "Stop session" = "Stop session" "Stop sharing" = "Stop sharing" "Stop spotlighting" = "Stop spotlighting" "Stop {n} {hex}" = "Stop {n} {hex}" +"Straight" = "Straight" "Stretch" = "Stretch" "Strikethrough" = "Strikethrough" "String" = "String" @@ -1365,6 +1392,7 @@ name = "English" "Text" = "Text" "Text align" = "Text align" "Text content" = "Text content" +"Text size" = "Text size" "Text style" = "Text style" "Text styles" = "Text styles" "Texts" = "Texts" @@ -1401,6 +1429,9 @@ name = "English" "This server is open to the public: no key needed." = "This server is open to the public: no key needed." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." "This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced." +"Thumbs up" = "Thumbs up" +"This version can't be read." = "This version can't be read." +"This version doesn't have it: all of it is new." = "This version doesn't have it: all of it is new." "Tidy up" = "Tidy up" "Tile" = "Tile" "Tile type" = "Tile type" @@ -1418,6 +1449,7 @@ name = "English" "Top padding" = "Top padding" "Top right radius" = "Top right radius" "Transform" = "Transform" +"Triangle" = "Triangle" "Triangle arrow" = "Triangle arrow" "Trigger" = "Trigger" "True" = "True" @@ -1443,7 +1475,7 @@ name = "English" "Unread" = "Unread" "Unresolve" = "Unresolve" "Unsaved changes" = "Unsaved changes" -"Unsaved changes in {names}. Close the window and lose them?" = "Unsaved changes in {names}. Close the window and lose them?" +"Unsaved changes in {names}. Save them before closing?" = "Unsaved changes in {names}. Save them before closing?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" "Unsaved changes: save first, or close again with {key} to discard" = "Unsaved changes: save first, or close again with {key} to discard" "Up" = "Up" @@ -1509,11 +1541,13 @@ name = "English" "WebSocket URL copied" = "WebSocket URL copied" "Wedge" = "Wedge" "Weight" = "Weight" +"What changed since a saved version" = "What changed since a saved version" "What you try here changes this preview and the code below, not the file" = "What you try here changes this preview and the code below, not the file" "What changed since it was marked ready for dev" = "What changed since it was marked ready for dev" "What is wrong with this file? The server's operator gets your report." = "What is wrong with this file? The server's operator gets your report." "What it's for" = "What it's for" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." +"When marked ready" = "When marked ready" "While hovering" = "While hovering" "While pressing" = "While pressing" "White" = "White" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..e172584c 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,7 +8,11 @@ name = "Español" status = "draft — needs native review" [ui] +"Arrow at the end" = "Flecha al final" +"Check" = "Visto" +"Chevron" = "Cheurón" "Compatibility notes" = "Notas de compatibilidad" +"Connector" = "Conector" "Couldn't download the variables" = "No se pudieron descargar las variables" "% of all commits" = "% del total de commits" @@ -250,7 +254,6 @@ status = "draft — needs native review" "Close tab" = "Cerrar pestaña" "Close version history" = "Cerrar historial de versiones" "Close when clicking outside" = "Cerrar al hacer clic fuera" -"Close without saving" = "Cerrar sin guardar" "Code" = "Código" "Code syntax" = "Sintaxis de código" "Collapse" = "Contraer" @@ -277,6 +280,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Empresa" "Compare changes" = "Comparar cambios" +"Compare with" = "Comparar con" "Completed" = "Completado" "Component" = "Componente" "Component name" = "Nombre del componente" @@ -380,6 +384,7 @@ status = "draft — needs native review" "Description" = "Descripción" "Description (optional)" = "Descripción (opcional)" "Design" = "Diseño" +"Design panel" = "Panel de diseño" "Design system starter" = "Base de sistema de diseño" "Desktop" = "Escritorio" "Destination" = "Destino" @@ -391,6 +396,7 @@ status = "draft — needs native review" "Detach variable" = "Separar variable" "Details" = "Detalles" "Dev Mode" = "Modo desarrollo" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "El modo desarrollo solo inspecciona: cambia a Diseño (Shift+D) para editar" "Dev resources" = "Recursos de desarrollo" "Development" = "Desarrollo" "Device" = "Dispositivo" @@ -407,6 +413,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Distribuir espacio horizontal" "Distribute vertical spacing" = "Distribuir espacio vertical" "Divider" = "Separador" +"Don't Save" = "No guardar" "Don't truncate" = "No truncar" "Done" = "Listo" "Done editing" = "Terminar edición" @@ -442,6 +449,7 @@ status = "draft — needs native review" "Effect settings" = "Ajustes del efecto" "Effect styles" = "Estilos de efecto" "Effects" = "Efectos" +"Elbow" = "En ángulo" "Ellipse" = "Elipse" "Ellipses" = "Elipses" "Else" = "Si no" @@ -578,6 +586,7 @@ status = "draft — needs native review" "Hanging list" = "Lista colgante" "Hanging punctuation" = "Puntuación colgante" "Hard light" = "Luz fuerte" +"Heart" = "Corazón" "Height" = "Alto" "Help" = "Ayuda" "Help and resources" = "Ayuda y recursos" @@ -590,6 +599,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Ocultar estos consejos (Primeros pasos en el menú Ayuda los vuelve a mostrar)" "Hide when publishing" = "Ocultar al publicar" "Highlight layers on hover" = "Resaltar capas al pasar el cursor" +"Highlighter" = "Resaltador" "Highlights" = "Luces" "Hold Alt" = "Mantén Alt" "Hold Alt+Z" = "Mantén Alt+Z" @@ -759,6 +769,7 @@ status = "draft — needs native review" "Mark as resolved" = "Marcar como resuelto" "Mark as unread" = "Marcar como no leído" "Mark as unresolved" = "Marcar como no resuelto" +"Marker" = "Rotulador" "Mask" = "Máscara" "Mass · Stiffness · Damping" = "Masa · Rigidez · Amortiguación" "Match (empty: the whole name)" = "Coincidencia (vacío: el nombre entero)" @@ -818,6 +829,7 @@ status = "draft — needs native review" "Named instance" = "Instancia con nombre" "Navigate to" = "Navegar a" "Nested instance" = "Instancia anidada" +"New board" = "Nueva pizarra" "New design file" = "Nuevo archivo de diseño" "Newest first" = "Más recientes primero" "Next or previous control in the focused region" = "Control siguiente o anterior de la región enfocada" @@ -884,6 +896,7 @@ status = "draft — needs native review" "Not read yet" = "Aún no leída" "Not shared" = "Sin compartir" "Note {n}" = "Nota {n}" +"Nothing changed since this version." = "Nada cambió desde esta versión." "Nothing changed since this was marked ready for dev." = "Nada cambió desde que se marcó como listo para desarrollo." "Nothing here yet. Set a frame's status to list it." = "Aún no hay nada. Asigna un estado a un marco para verlo aquí." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Aún no hay nada en línea. Usa Compartir en un archivo para ponerlo en línea y aparecerá aquí." @@ -921,6 +934,7 @@ status = "draft — needs native review" "Open branch" = "Abrir rama" "Open file" = "Abrir archivo" "Open files" = "Archivos abiertos" +"Open presentations" = "Presentaciones abiertas" "Open in focus view" = "Abrir en vista de enfoque" "Open in new window" = "Abrir en una ventana nueva" "Open invite links in FigCraft" = "Abrir enlaces de invitación en FigCraft" @@ -959,6 +973,7 @@ status = "draft — needs native review" "Page deleted" = "Página eliminada" "Pages" = "Páginas" "Pages added to the document" = "Páginas añadidas al documento" +"Pink" = "Rosa" "Placed on the current page" = "Colocado en la página actual" "Pages: {pages}" = "Páginas: {pages}" "Paint bucket" = "Bote de pintura" @@ -1003,6 +1018,7 @@ status = "draft — needs native review" "Plugins & widgets" = "Plugins y widgets" "Plus darker" = "Más oscuro" "Plus lighter" = "Más claro" +"Plus one" = "Más uno" "Point" = "Punto" "Points" = "Puntos" "Polygon" = "Polígono" @@ -1051,6 +1067,7 @@ status = "draft — needs native review" "Push" = "Empujar" "Push changes to main component" = "Enviar cambios al componente principal" "Put text on path" = "Poner texto en trazado" +"Question" = "Pregunta" "R" = "R" "Radial" = "Radial" "Radius" = "Radio" @@ -1097,6 +1114,7 @@ status = "draft — needs native review" "Removed" = "Eliminado" "Removed from the server" = "Quitado del servidor" "Removed since it was marked" = "Eliminada desde que se marcó" +"Removed since this version" = "Eliminado desde esta versión" "Rename" = "Cambiar nombre" "Rename duplicated layers" = "Renombrar capas duplicadas" "Rename folder" = "Cambiar nombre de la carpeta" @@ -1154,6 +1172,7 @@ status = "draft — needs native review" "Rotation jitter" = "Variación de rotación" "Round" = "Redondo" "Rounded" = "Redondeado" +"Rounded rectangle" = "Rectángulo redondeado" "Row" = "Fila" "Row gap" = "Espacio entre filas" "Row sizes" = "Tamaños de fila" @@ -1230,6 +1249,7 @@ status = "draft — needs native review" "Shape" = "Forma" "Shape builder" = "Generador de formas" "Shape tools" = "Herramientas de forma" +"Shapes" = "Formas" "Share" = "Compartir" "Share this file" = "Compartir este archivo" "Share with a code" = "Comparte con un código" @@ -1296,10 +1316,13 @@ status = "draft — needs native review" "Space+drag" = "Espacio+arrastrar" "Spacing" = "Espaciado" "Spam, stolen work, abuse…" = "Spam, obra robada, abuso…" +"Speech bubble" = "Bocadillo" "Splay" = "Apertura" "Spotlight me" = "Ponerme en el foco" "Spread" = "Extensión" "Square" = "Cuadrado" +"Stamp" = "Sello" +"Stamps" = "Sellos" "Standard" = "Estándar" "Standard ligatures" = "Ligaduras estándar" "Star" = "Estrella" @@ -1316,12 +1339,16 @@ status = "draft — needs native review" "Starting to share…" = "Empezando a compartir…" "Status" = "Estado" "Sticky" = "Adherido" +"Sticky note" = "Nota adhesiva" +"Sticky note colors" = "Colores de las notas adhesivas" +"Sticky notes, shapes and connectors" = "Notas adhesivas, formas y conectores" "Stop" = "Parar" "Stop following {name}" = "Dejar de seguir a {name}" "Stop session" = "Detener sesión" "Stop sharing" = "Dejar de compartir" "Stop spotlighting" = "Salir del foco" "Stop {n} {hex}" = "Parada {n} {hex}" +"Straight" = "Recto" "Stretch" = "Estirar" "Strikethrough" = "Tachado" "String" = "Cadena" @@ -1366,6 +1393,7 @@ status = "draft — needs native review" "Text" = "Texto" "Text align" = "Alineación del texto" "Text content" = "Contenido de texto" +"Text size" = "Tamaño del texto" "Text style" = "Estilo de texto" "Text styles" = "Estilos de texto" "Texts" = "Textos" @@ -1402,6 +1430,9 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Este servidor está abierto al público: no hace falta clave." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor necesita su clave: añade ?key=… a la dirección (`figcraft-server key` la muestra en el equipo del servidor)." "This server takes files only with its key: see Advanced." = "Este servidor solo acepta archivos con su clave: mira Avanzado." +"Thumbs up" = "Pulgar arriba" +"This version can't be read." = "No se puede leer esta versión." +"This version doesn't have it: all of it is new." = "Esta versión no lo tiene: todo es nuevo." "Tidy up" = "Ordenar" "Tile" = "Mosaico" "Tile type" = "Tipo de mosaico" @@ -1419,6 +1450,7 @@ status = "draft — needs native review" "Top padding" = "Margen interno superior" "Top right radius" = "Radio superior derecho" "Transform" = "Transformar" +"Triangle" = "Triángulo" "Triangle arrow" = "Flecha triangular" "Trigger" = "Desencadenante" "True" = "Verdadero" @@ -1444,7 +1476,7 @@ status = "draft — needs native review" "Unread" = "No leídos" "Unresolve" = "Reabrir" "Unsaved changes" = "Cambios sin guardar" -"Unsaved changes in {names}. Close the window and lose them?" = "Hay cambios sin guardar en {names}. ¿Cerrar la ventana y perderlos?" +"Unsaved changes in {names}. Save them before closing?" = "Hay cambios sin guardar en {names}. ¿Guardarlos antes de cerrar?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Cambios sin guardar: guarda primero ({save}) o cierra la pestaña con {alt}+clic para descartarlos" "Unsaved changes: save first, or close again with {key} to discard" = "Cambios sin guardar: guarda primero o vuelve a cerrar con {key} para descartarlos" "Up" = "Arriba" @@ -1510,11 +1542,13 @@ status = "draft — needs native review" "WebSocket URL copied" = "URL de WebSocket copiada" "Wedge" = "Cuña" "Weight" = "Grosor" +"What changed since a saved version" = "Qué cambió desde una versión guardada" "What you try here changes this preview and the code below, not the file" = "Lo que pruebes aquí cambia esta vista previa y el código de abajo, no el archivo" "What changed since it was marked ready for dev" = "Qué cambió desde que se marcó como listo para desarrollo" "What is wrong with this file? The server's operator gets your report." = "¿Qué tiene de malo este archivo? El operador del servidor recibirá tu denuncia." "What it's for" = "Para qué sirve" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "No se sabe desde qué cambió: cambió antes de que FigCraft guardara copias de los marcos listos para desarrollo. Resuelve el cambio (o vuelve a marcarlo como listo) para comparar a partir de ahora." +"When marked ready" = "Al marcarlo como listo" "While hovering" = "Mientras se pasa el puntero" "While pressing" = "Mientras se presiona" "White" = "Blanco" @@ -1784,6 +1818,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Redondear a píxeles" "arrange.spacing" = "Espaciado" "arrange.tidy" = "Ordenar" +"board.connector" = "Conector" +"board.ink" = "Trazo de rotulador" +"board.label" = "Etiqueta del conector" +"board.nextSticky" = "Siguiente nota adhesiva" +"board.setColor" = "Color en la pizarra" +"board.setStyle" = "Estilo en la pizarra" +"board.shape" = "Forma con texto" +"board.stamp" = "Sello" +"board.sticky" = "Nota adhesiva" "bool.exclude" = "Excluir selección" "bool.flatten" = "Acoplar booleana" "bool.intersect" = "Intersecar selección" @@ -1910,6 +1953,7 @@ status = "draft — needs native review" "file.import" = "Importar" "file.moveTab" = "Mover pestaña" "file.new" = "Nuevo archivo de diseño" +"file.newBoard" = "Nueva pizarra" "file.newFromTemplate" = "Nuevo desde plantilla" "file.open" = "Abrir" "file.openRecent" = "Abrir reciente" @@ -2184,10 +2228,14 @@ status = "draft — needs native review" "text.weightUp" = "Aumentar peso de fuente" "tool.arrow" = "Flecha" "tool.comment" = "Comentario" +"tool.connector" = "Conector" "tool.ellipse" = "Elipse" +"tool.eraser" = "Borrador" "tool.frame" = "Marco" "tool.hand" = "Mano" +"tool.highlighter" = "Resaltador" "tool.line" = "Línea" +"tool.marker" = "Rotulador" "tool.move" = "Mover" "tool.pen" = "Pluma" "tool.pencil" = "Lápiz" @@ -2199,9 +2247,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Ancla de escala" "tool.section" = "Sección" "tool.set" = "Elegir herramienta" +"tool.shape" = "Formas" "tool.shapeBuilder" = "Generador de formas" "tool.slice" = "Sector" +"tool.stamp" = "Sello" "tool.star" = "Estrella" +"tool.sticky" = "Nota adhesiva" "tool.text" = "Texto" "transform.move" = "Mover" "transform.moveTo" = "Mover a" @@ -2263,11 +2314,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Herramienta de edición vectorial" "vector.edit.transform" = "Transformar puntos" "vector.edit.widthPoint" = "Punto de ancho" +"vector.fillRule" = "Establecer regla de relleno" "vector.flatten" = "Acoplar" "vector.join" = "Unir selección" "vector.offset" = "Desplazar trazado" "vector.outlineStroke" = "Contornear trazo" "vector.reverse" = "Invertir dirección del trazado" +"vector.reverseLoop" = "Invertir bucle" "vector.simplify" = "Simplificar vector" "vector.smoothJoin" = "Unir selección con suavizado" "version.autosave" = "Guardar versión automáticamente" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 205a9d29..f18ffeaf 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -127,6 +127,7 @@ status = "draft — needs native review" "Archived" = "Arkistoitu" "Arrange" = "Järjestä" "Arrow" = "Nuoli" +"Arrow at the end" = "Nuoli loppuun" "As typed" = "Kirjoitetussa muodossa" "Asking the server…" = "Kysytään palvelimelta…" "Assets" = "Resurssit" @@ -221,8 +222,10 @@ status = "draft — needs native review" "Changes on main" = "Päähaaran muutokset" "Changes on the branch" = "Haaran muutokset" "Changes unknown" = "Muutokset tuntemattomia" +"Check" = "Valintamerkki" "Checking the fonts installed on this computer…" = "Tarkistetaan tämän tietokoneen asennettuja fontteja…" "Checking the server…" = "Tarkistetaan palvelinta…" +"Chevron" = "Kulmanuoli" "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Valitse yllä React, Leptos, SwiftUI tai Compose yhdistääksesi tämän komponentin sen koodiin." "Choose another profile to change it." = "Vaihda valitsemalla toinen profiili." "Choose collection" = "Valitse kokoelma" @@ -245,7 +248,6 @@ status = "draft — needs native review" "Close tab" = "Sulje välilehti" "Close version history" = "Sulje versiohistoria" "Close when clicking outside" = "Sulje napsautettaessa ulkopuolelle" -"Close without saving" = "Sulje tallentamatta" "Code" = "Koodi" "Code or invite link" = "Koodi tai kutsulinkki" "Code syntax" = "Koodin syntaksi" @@ -290,6 +292,7 @@ status = "draft — needs native review" "Connected" = "Yhdistetty" "Connected to code: {from}" = "Yhdistetty koodiin: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Yhteys katkesi, yhdistetään uudelleen (yritys {attempt})…" +"Connector" = "Yhdistin" "Constrain proportions" = "Säilytä mittasuhteet" "Constraints" = "Rajoitteet" "Content" = "Sisältö" @@ -380,6 +383,7 @@ status = "draft — needs native review" "Description" = "Kuvaus" "Description (optional)" = "Kuvaus (valinnainen)" "Design" = "Suunnittelu" +"Design panel" = "Suunnittelupaneeli" "Design system starter" = "Muotoilujärjestelmän aloituspohja" "Desktop" = "Työpöytä" "Destination" = "Kohde" @@ -407,6 +411,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Jaa vaakavälit tasan" "Distribute vertical spacing" = "Jaa pystyvälit tasan" "Divider" = "Erotin" +"Don't Save" = "Älä tallenna" "Don't truncate" = "Älä lyhennä" "Done" = "Valmis" "Done editing" = "Lopeta muokkaus" @@ -442,6 +447,7 @@ status = "draft — needs native review" "Effect settings" = "Tehosteen asetukset" "Effect styles" = "Tehostetyylit" "Effects" = "Tehosteet" +"Elbow" = "Kulmikas" "Ellipse" = "Ellipsi" "Ellipses" = "Ellipsit" "Else" = "Muuten" @@ -578,6 +584,7 @@ status = "draft — needs native review" "Hanging list" = "Riippuva luettelo" "Hanging punctuation" = "Välimerkkien ulonnus" "Hard light" = "Kova valo" +"Heart" = "Sydän" "Height" = "Korkeus" "Help" = "Ohje" "Help and resources" = "Ohje ja resurssit" @@ -590,6 +597,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Kätke nämä vinkit (Ohje-valikon Aloittaminen näyttää ne uudelleen)" "Hide when publishing" = "Kätke julkaistaessa" "Highlight layers on hover" = "Korosta tasot osoitettaessa" +"Highlighter" = "Korostuskynä" "Highlights" = "Huippuvalot" "Hold Alt" = "Pidä Alt painettuna" "Hold Alt+Z" = "Pidä Alt+Z painettuna" @@ -756,6 +764,7 @@ status = "draft — needs native review" "Mark as resolved" = "Merkitse ratkaistuksi" "Mark as unread" = "Merkitse lukemattomaksi" "Mark as unresolved" = "Merkitse ratkaisemattomaksi" +"Marker" = "Tussi" "Mask" = "Maski" "Mass · Stiffness · Damping" = "Massa · jäykkyys · vaimennus" "Match (empty: the whole name)" = "Etsittävä (tyhjä: koko nimi)" @@ -815,6 +824,7 @@ status = "draft — needs native review" "Named instance" = "Nimetty ilmentymä" "Navigate to" = "Siirry kohteeseen" "Nested instance" = "Sisäkkäinen ilmentymä" +"New board" = "Uusi taulu" "New design file" = "Uusi suunnittelutiedosto" "Newest first" = "Uusimmat ensin" "Next frame" = "Seuraava kehys" @@ -918,6 +928,7 @@ status = "draft — needs native review" "Open branch" = "Avaa haara" "Open file" = "Avaa tiedosto" "Open files" = "Avaa tiedostoja" +"Open presentations" = "Avoimet esitykset" "Open in focus view" = "Avaa kohdistusnäkymässä" "Open in new window" = "Avaa uudessa ikkunassa" "Open invite links in FigCraft" = "Avaa kutsulinkit FigCraftissa" @@ -987,6 +998,7 @@ status = "draft — needs native review" "Pick the Frame tool, then drag on the canvas or choose a device size" = "Valitse Kehys-työkalu ja vedä piirtoalueella tai valitse laitteen koko" "Pin a property" = "Kiinnitä ominaisuus" "Pinch" = "Nipistys" +"Pink" = "Vaaleanpunainen" "Pixel grid" = "Pikseliruudukko" "Pixel preview 1x" = "Pikseliesikatselu 1x" "Pixel preview 2x" = "Pikseliesikatselu 2x" @@ -1001,6 +1013,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Liitännäiset toimivat FigCraftin eristetyssä ympäristössä ilman tiedostoja, verkkoa tai kelloa. Asenna vain luottamiasi liitännäisiä." "Plus darker" = "Tummempi summa" "Plus lighter" = "Vaaleampi summa" +"Plus one" = "Plus yksi" "Point" = "Piste" "Points" = "Pisteet" "Polygon" = "Monikulmio" @@ -1048,6 +1061,7 @@ status = "draft — needs native review" "Push" = "Työntö" "Push changes to main component" = "Siirrä muutokset pääkomponenttiin" "Put text on path" = "Sijoita teksti polulle" +"Question" = "Kysymys" "R" = "R" "Radial" = "Säteittäinen" "Radius" = "Säde" @@ -1148,6 +1162,7 @@ status = "draft — needs native review" "Rotation jitter" = "Kierron vaihtelu" "Round" = "Pyöreä" "Rounded" = "Pyöristetty" +"Rounded rectangle" = "Pyöristetty suorakulmio" "Row" = "Rivi" "Row gap" = "Rivien väli" "Row sizes" = "Rivien koot" @@ -1223,6 +1238,7 @@ status = "draft — needs native review" "Shape" = "Muoto" "Shape builder" = "Muodonrakennin" "Shape tools" = "Muototyökalut" +"Shapes" = "Muodot" "Share" = "Jaa" "Share this file" = "Jaa tämä tiedosto" "Share with a code" = "Jaa koodilla" @@ -1289,10 +1305,13 @@ status = "draft — needs native review" "Space+drag" = "Space+drag" "Spacing" = "Välistys" "Spam, stolen work, abuse…" = "Roskaposti, varastettu työ, väärinkäyttö…" +"Speech bubble" = "Puhekupla" "Splay" = "Harotus" "Spotlight me" = "Nosta minut esiin" "Spread" = "Leviäminen" "Square" = "Neliö" +"Stamp" = "Leima" +"Stamps" = "Leimat" "Standard" = "Vakio" "Standard ligatures" = "Tavalliset ligatuurit" "Star" = "Tähti" @@ -1309,12 +1328,16 @@ status = "draft — needs native review" "Starting to share…" = "Aloitetaan jakamista…" "Status" = "Tila" "Sticky" = "Kiinnittyvä" +"Sticky note" = "Muistilappu" +"Sticky note colors" = "Muistilappujen värit" +"Sticky notes, shapes and connectors" = "Muistilaput, muodot ja yhdistimet" "Stop" = "Pysäytä" "Stop following {name}" = "Lopeta käyttäjän {name} seuraaminen" "Stop session" = "Lopeta istunto" "Stop sharing" = "Lopeta jakaminen" "Stop spotlighting" = "Lopeta esiin nostaminen" "Stop {n} {hex}" = "Piste {n} {hex}" +"Straight" = "Suora" "Stretch" = "Venytys" "Strikethrough" = "Yliviivaus" "String" = "Merkkijono" @@ -1359,6 +1382,7 @@ status = "draft — needs native review" "Text" = "Teksti" "Text align" = "Tekstin tasaus" "Text content" = "Tekstisisältö" +"Text size" = "Tekstin koko" "Text style" = "Tekstityyli" "Text styles" = "Tekstityylit" "Texts" = "Tekstit" @@ -1393,6 +1417,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Tämä palvelin on julkinen: avainta ei tarvita." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Tämä palvelin tarvitsee avaimen. Lisää osoitteeseen ?key=… (`figcraft-server key` tulostaa avaimen palvelimen koneella)." "This server takes files only with its key: see Advanced." = "Tämä palvelin ottaa tiedostoja vastaan vain avaimellaan. Katso Lisäasetukset." +"Thumbs up" = "Peukku ylös" "Tidy up" = "Järjestä tasavälein" "Tile" = "Laatoitus" "Tile type" = "Laatan tyyppi" @@ -1409,6 +1434,7 @@ status = "draft — needs native review" "Top left radius" = "Vasemman yläkulman säde" "Top right radius" = "Oikean yläkulman säde" "Transform" = "Muunna" +"Triangle" = "Kolmio" "Triangle arrow" = "Kolmionuoli" "Trigger" = "Laukaisin" "True" = "Tosi" @@ -1434,7 +1460,7 @@ status = "draft — needs native review" "Unread" = "Lukematon" "Unresolve" = "Merkitse ratkaisemattomaksi" "Unsaved changes" = "Tallentamattomat muutokset" -"Unsaved changes in {names}. Close the window and lose them?" = "Tallentamattomia muutoksia kohteissa {names}. Suljetaanko ikkuna ja hylätään ne?" +"Unsaved changes in {names}. Save them before closing?" = "Tallentamattomia muutoksia kohteissa {names}. Tallennetaanko ne ennen sulkemista?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Tallentamattomia muutoksia: tallenna ensin ({save}) tai hylkää sulkemalla välilehti {alt}-napsautuksella" "Unsaved changes: save first, or close again with {key} to discard" = "Tallentamattomia muutoksia: tallenna ensin tai hylkää sulkemalla uudelleen näppäimellä {key}" "Up" = "Ylös" @@ -1771,6 +1797,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Pyöristä pikseleihin" "arrange.spacing" = "Välistys" "arrange.tidy" = "Järjestä tasavälein" +"board.connector" = "Yhdistin" +"board.ink" = "Tussiviiva" +"board.label" = "Yhdistimen nimiö" +"board.nextSticky" = "Seuraava muistilappu" +"board.setColor" = "Taulun väri" +"board.setStyle" = "Taulun tyyli" +"board.shape" = "Muoto ja teksti" +"board.stamp" = "Leima" +"board.sticky" = "Muistilappu" "bool.exclude" = "Sulje valinnan päällekkäinen alue pois" "bool.flatten" = "Yhdistä Boolen muoto vektoriksi" "bool.intersect" = "Leikkaa valinnan yhteinen alue" @@ -1897,6 +1932,7 @@ status = "draft — needs native review" "file.import" = "Tuo" "file.moveTab" = "Siirrä välilehti" "file.new" = "Uusi suunnittelutiedosto" +"file.newBoard" = "Uusi taulu" "file.newFromTemplate" = "Uusi mallista" "file.open" = "Avaa" "file.openRecent" = "Avaa viimeksi avattu" @@ -2171,10 +2207,14 @@ status = "draft — needs native review" "text.weightUp" = "Lisää fontin paksuutta" "tool.arrow" = "Nuoli" "tool.comment" = "Kommentti" +"tool.connector" = "Yhdistin" "tool.ellipse" = "Ellipsi" +"tool.eraser" = "Pyyhekumi" "tool.frame" = "Kehys" "tool.hand" = "Käsi" +"tool.highlighter" = "Korostuskynä" "tool.line" = "Viiva" +"tool.marker" = "Tussi" "tool.move" = "Siirrä" "tool.pen" = "Kynä" "tool.pencil" = "Lyijykynä" @@ -2186,9 +2226,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Skaalauksen ankkuri" "tool.section" = "Osio" "tool.set" = "Valitse työkalu" +"tool.shape" = "Muodot" "tool.shapeBuilder" = "Muodonrakennin" "tool.slice" = "Viipale" +"tool.stamp" = "Leima" "tool.star" = "Tähti" +"tool.sticky" = "Muistilappu" "tool.text" = "Teksti" "transform.move" = "Siirrä" "transform.moveTo" = "Siirrä kohteeseen" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index b038b335..e5823983 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -125,6 +125,7 @@ status = "draft — needs native review" "Archived" = "Archivée" "Arrange" = "Disposer" "Arrow" = "Flèche" +"Arrow at the end" = "Flèche à la fin" "As typed" = "Tel que saisi" "Asking the server…" = "Interrogation du serveur…" "Assets" = "Ressources" @@ -198,7 +199,9 @@ status = "draft — needs native review" "C or ⇧C" = "C ou ⇧C" "CMYK colour" = "Couleur CMJN" "Can view" = "Peut consulter" +"Check" = "Coche" "Checking the server…" = "Vérification auprès du serveur…" +"Chevron" = "Chevron" "Clear from this list" = "Effacer de cette liste" "Code or invite link" = "Code ou lien d’invitation" "Color profile: {name}" = "Profil colorimétrique : {name}" @@ -244,7 +247,6 @@ status = "draft — needs native review" "Close tab" = "Fermer l’onglet" "Close version history" = "Fermer l’historique des versions" "Close when clicking outside" = "Fermer en cliquant à l’extérieur" -"Close without saving" = "Fermer sans enregistrer" "Code" = "Code" "Code syntax" = "Syntaxe du code" "Collapse" = "Réduire" @@ -272,6 +274,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Entreprise" "Compare changes" = "Comparer les modifications" +"Compare with" = "Comparer avec" "Compatibility notes" = "Notes de compatibilité" "Completed" = "Terminé" "Component" = "Composant" @@ -287,6 +290,7 @@ status = "draft — needs native review" "Connected" = "Connecté" "Connected to code: {from}" = "Relié au code : {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Connexion perdue, reconnexion (tentative {attempt})…" +"Connector" = "Connecteur" "Constrain proportions" = "Conserver les proportions" "Constraints" = "Contraintes" "Content" = "Contenu" @@ -377,6 +381,7 @@ status = "draft — needs native review" "Description" = "Description" "Description (optional)" = "Description (facultative)" "Design" = "Design" +"Design panel" = "Panneau Design" "Design system starter" = "Base de système de design" "Desktop" = "Ordinateur" "Destination" = "Destination" @@ -388,6 +393,7 @@ status = "draft — needs native review" "Detach variable" = "Détacher la variable" "Details" = "Détails" "Dev Mode" = "Mode Dev" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Le mode Dev sert seulement à inspecter : passez en Design (Shift+D) pour modifier" "Dev resources" = "Ressources de développement" "Development" = "Développement" "Device" = "Appareil" @@ -404,6 +410,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Répartir l’espacement horizontal" "Distribute vertical spacing" = "Répartir l’espacement vertical" "Divider" = "Séparateur" +"Don't Save" = "Ne pas enregistrer" "Don't truncate" = "Ne pas tronquer" "Done" = "Terminé" "Done editing" = "Terminer la modification" @@ -439,6 +446,7 @@ status = "draft — needs native review" "Effect settings" = "Réglages de l’effet" "Effect styles" = "Styles d’effet" "Effects" = "Effets" +"Elbow" = "Coudé" "Ellipse" = "Ellipse" "Ellipses" = "Ellipses" "Else" = "Sinon" @@ -575,6 +583,7 @@ status = "draft — needs native review" "Hanging list" = "Liste en retrait" "Hanging punctuation" = "Ponctuation en débord" "Hard light" = "Lumière dure" +"Heart" = "Cœur" "Height" = "Hauteur" "Help" = "Aide" "Help and resources" = "Aide et ressources" @@ -587,6 +596,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Masquer ces astuces (Premiers pas dans le menu Aide les affiche de nouveau)" "Hide when publishing" = "Masquer à la publication" "Highlight layers on hover" = "Mettre en évidence les calques au survol" +"Highlighter" = "Surligneur" "Highlights" = "Hautes lumières" "Hold Alt" = "Maintenir Alt" "Hold Alt+Z" = "Maintenir Alt+Z" @@ -754,6 +764,7 @@ status = "draft — needs native review" "Mark as resolved" = "Marquer comme résolu" "Mark as unread" = "Marquer comme non lu" "Mark as unresolved" = "Marquer comme non résolu" +"Marker" = "Marqueur" "Mask" = "Masque" "Mass · Stiffness · Damping" = "Masse · Rigidité · Amortissement" "Match (empty: the whole name)" = "Correspondance (vide : le nom entier)" @@ -812,6 +823,7 @@ status = "draft — needs native review" "Named instance" = "Instance nommée" "Navigate to" = "Naviguer vers" "Nested instance" = "Instance imbriquée" +"New board" = "Nouveau tableau blanc" "New design file" = "Nouveau fichier de design" "Newest first" = "Plus récent d’abord" "Next frame" = "Cadre suivant" @@ -877,6 +889,7 @@ status = "draft — needs native review" "Not read yet" = "Pas encore lu" "Not shared" = "Non partagé" "Note {n}" = "Note {n}" +"Nothing changed since this version." = "Rien n’a changé depuis cette version." "Nothing changed since this was marked ready for dev." = "Rien n’a changé depuis que ceci a été marqué prêt pour le dev." "Nothing here yet. Set a frame's status to list it." = "Rien pour l’instant. Attribuez un statut à un cadre pour le lister ici." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Rien en ligne pour l’instant. Utilisez Partager sur un fichier pour le mettre en ligne et il apparaîtra ici." @@ -914,6 +927,7 @@ status = "draft — needs native review" "Open branch" = "Ouvrir la branche" "Open file" = "Ouvrir un fichier" "Open files" = "Fichiers ouverts" +"Open presentations" = "Présentations ouvertes" "Open in focus view" = "Ouvrir en vue ciblée" "Open in new window" = "Ouvrir dans une nouvelle fenêtre" "Open invite links in FigCraft" = "Ouvrir les liens d’invitation dans FigCraft" @@ -953,6 +967,7 @@ status = "draft — needs native review" "Pages" = "Pages" "Pages added to the document" = "Pages ajoutées au document" "Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil" +"Pink" = "Rose" "Placed on the current page" = "Placé sur la page actuelle" "Pages: {pages}" = "Pages : {pages}" "Paint bucket" = "Pot de peinture" @@ -997,6 +1012,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Les plugins s’exécutent dans le bac à sable de FigCraft, sans fichiers, réseau ni horloge. N’installez que des plugins de confiance." "Plus darker" = "Plus foncé" "Plus lighter" = "Plus clair" +"Plus one" = "Plus un" "Point" = "Point" "Points" = "Points" "Polygon" = "Polygone" @@ -1044,6 +1060,7 @@ status = "draft — needs native review" "Push" = "Pousser" "Push changes to main component" = "Transmettre les modifications au composant principal" "Put text on path" = "Placer le texte sur un tracé" +"Question" = "Question" "R" = "R" "Radial" = "Radial" "Radius" = "Rayon" @@ -1090,6 +1107,7 @@ status = "draft — needs native review" "Removed" = "Supprimé" "Removed from the server" = "Retiré du serveur" "Removed since it was marked" = "Supprimé depuis le marquage" +"Removed since this version" = "Supprimé depuis cette version" "Rename" = "Renommer" "Rename duplicated layers" = "Renommer les calques dupliqués" "Rename folder" = "Renommer le dossier" @@ -1145,6 +1163,7 @@ status = "draft — needs native review" "Rotation jitter" = "Variation de rotation" "Round" = "Arrondi" "Rounded" = "Arrondi" +"Rounded rectangle" = "Rectangle arrondi" "Row" = "Ligne" "Row gap" = "Espacement des lignes" "Row sizes" = "Tailles des lignes" @@ -1218,6 +1237,7 @@ status = "draft — needs native review" "Shape" = "Forme" "Shape builder" = "Générateur de formes" "Shape tools" = "Outils de forme" +"Shapes" = "Formes" "Share" = "Partager" "Share this file" = "Partager ce fichier" "Share with a code" = "Partager avec un code" @@ -1284,10 +1304,13 @@ status = "draft — needs native review" "Space+drag" = "Espace+glisser" "Spacing" = "Espacement" "Spam, stolen work, abuse…" = "Spam, travail volé, abus…" +"Speech bubble" = "Bulle" "Splay" = "Évasement" "Spotlight me" = "Me mettre sous les projecteurs" "Spread" = "Étendue" "Square" = "Carré" +"Stamp" = "Tampon" +"Stamps" = "Tampons" "Standard" = "Standard" "Standard ligatures" = "Ligatures standard" "Star" = "Étoile" @@ -1304,12 +1327,16 @@ status = "draft — needs native review" "Starting to share…" = "Démarrage du partage…" "Status" = "Statut" "Sticky" = "Collant" +"Sticky note" = "Note autocollante" +"Sticky note colors" = "Couleurs des notes autocollantes" +"Sticky notes, shapes and connectors" = "Notes autocollantes, formes et connecteurs" "Stop" = "Arrêter" "Stop following {name}" = "Ne plus suivre {name}" "Stop session" = "Arrêter la session" "Stop sharing" = "Arrêter le partage" "Stop spotlighting" = "Quitter les projecteurs" "Stop {n} {hex}" = "Arrêt {n} {hex}" +"Straight" = "Droit" "Stretch" = "Étirer" "Strikethrough" = "Barré" "String" = "Chaîne" @@ -1354,6 +1381,7 @@ status = "draft — needs native review" "Text" = "Texte" "Text align" = "Alignement du texte" "Text content" = "Contenu du texte" +"Text size" = "Taille du texte" "Text style" = "Style de texte" "Text styles" = "Styles de texte" "Texts" = "Textes" @@ -1388,6 +1416,9 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Ce serveur est ouvert au public : aucune clé nécessaire." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ce serveur exige sa clé : ajoutez ?key=… à l’adresse (`figcraft-server key` l’affiche sur la machine du serveur)." "This server takes files only with its key: see Advanced." = "Ce serveur n’accepte les fichiers qu’avec sa clé : voir Avancé." +"Thumbs up" = "Pouce levé" +"This version can't be read." = "Cette version est illisible." +"This version doesn't have it: all of it is new." = "Cette version ne le contient pas : tout est nouveau." "Tidy up" = "Ranger" "Tile" = "Mosaïque" "Tile type" = "Type de mosaïque" @@ -1404,6 +1435,7 @@ status = "draft — needs native review" "Top left radius" = "Rayon supérieur gauche" "Top right radius" = "Rayon supérieur droit" "Transform" = "Transformer" +"Triangle" = "Triangle" "Triangle arrow" = "Flèche triangulaire" "Trigger" = "Déclencheur" "True" = "Vrai" @@ -1428,7 +1460,7 @@ status = "draft — needs native review" "Unread" = "Non lus" "Unresolve" = "Rouvrir" "Unsaved changes" = "Modifications non enregistrées" -"Unsaved changes in {names}. Close the window and lose them?" = "Modifications non enregistrées dans {names}. Fermer la fenêtre et les perdre ?" +"Unsaved changes in {names}. Save them before closing?" = "Modifications non enregistrées dans {names}. Les enregistrer avant de fermer ?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Modifications non enregistrées : enregistrez d’abord ({save}) ou fermez l’onglet avec {alt}+clic pour les abandonner" "Unsaved changes: save first, or close again with {key} to discard" = "Modifications non enregistrées : enregistrez d’abord ou fermez à nouveau avec {key} pour les abandonner" "Up" = "Haut" @@ -1494,10 +1526,12 @@ status = "draft — needs native review" "Websites" = "Sites web" "Wedge" = "Coin" "Weight" = "Graisse" +"What changed since a saved version" = "Ce qui a changé depuis une version enregistrée" "What changed since it was marked ready for dev" = "Ce qui a changé depuis le marquage prêt pour le dev" "What is wrong with this file? The server's operator gets your report." = "Qu’est-ce qui ne va pas avec ce fichier ? L’opérateur du serveur reçoit votre signalement." "What it's for" = "À quoi il sert" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "L’état d’origine de cette modification est inconnu : elle date d’avant que FigCraft ne conserve une copie des cadres prêts pour le dev. Résolvez la modification (ou marquez-la à nouveau comme prête) pour comparer à partir de maintenant." +"When marked ready" = "Au marquage prêt" "What you try here changes this preview and the code below, not the file" = "Ce que vous essayez ici change cet aperçu et le code ci-dessous, pas le fichier" "While hovering" = "Pendant le survol" "While pressing" = "Pendant l’appui" @@ -1764,6 +1798,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Arrondir aux pixels" "arrange.spacing" = "Espacement" "arrange.tidy" = "Ranger" +"board.connector" = "Connecteur" +"board.ink" = "Trait de marqueur" +"board.label" = "Libellé du connecteur" +"board.nextSticky" = "Note autocollante suivante" +"board.setColor" = "Couleur du tableau" +"board.setStyle" = "Style du tableau" +"board.shape" = "Forme avec texte" +"board.stamp" = "Tampon" +"board.sticky" = "Note autocollante" "bool.exclude" = "Exclure la sélection" "bool.flatten" = "Aplatir le booléen" "bool.intersect" = "Intersection de la sélection" @@ -1890,6 +1933,7 @@ status = "draft — needs native review" "file.import" = "Importer" "file.moveTab" = "Déplacer l’onglet" "file.new" = "Nouveau fichier de design" +"file.newBoard" = "Nouveau tableau blanc" "file.newFromTemplate" = "Nouveau à partir d’un modèle" "file.open" = "Ouvrir" "file.openRecent" = "Ouvrir un fichier récent" @@ -2161,10 +2205,14 @@ status = "draft — needs native review" "text.weightUp" = "Augmenter la graisse" "tool.arrow" = "Flèche" "tool.comment" = "Commentaire" +"tool.connector" = "Connecteur" "tool.ellipse" = "Ellipse" +"tool.eraser" = "Gomme" "tool.frame" = "Cadre" "tool.hand" = "Main" +"tool.highlighter" = "Surligneur" "tool.line" = "Ligne" +"tool.marker" = "Marqueur" "tool.move" = "Déplacer" "tool.pen" = "Plume" "tool.pencil" = "Crayon" @@ -2176,9 +2224,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Ancre de mise à l’échelle" "tool.section" = "Section" "tool.set" = "Choisir un outil" +"tool.shape" = "Formes" "tool.shapeBuilder" = "Générateur de formes" "tool.slice" = "Tranche" +"tool.stamp" = "Tampon" "tool.star" = "Étoile" +"tool.sticky" = "Note autocollante" "tool.text" = "Texte" "transform.move" = "Déplacer" "transform.moveTo" = "Déplacer vers" @@ -2240,11 +2291,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Outil de modification vectorielle" "vector.edit.transform" = "Transformer les points" "vector.edit.widthPoint" = "Point de largeur" +"vector.fillRule" = "Définir la règle de remplissage" "vector.flatten" = "Aplatir" "vector.join" = "Joindre la sélection" "vector.offset" = "Décaler le tracé" "vector.outlineStroke" = "Convertir le contour en tracé" "vector.reverse" = "Inverser le sens du tracé" +"vector.reverseLoop" = "Inverser la boucle" "vector.simplify" = "Simplifier le vecteur" "vector.smoothJoin" = "Joindre la sélection en lissant" "version.autosave" = "Enregistrer automatiquement une version" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index f21e1ca3..c06f2dff 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -124,6 +124,7 @@ status = "draft — needs native review" "Archived" = "Diarsipkan" "Arrange" = "Susun" "Arrow" = "Panah" +"Arrow at the end" = "Panah di ujung" "As typed" = "Sesuai ketikan" "Asking the server…" = "Menghubungi server…" "Assets" = "Aset" @@ -197,7 +198,9 @@ status = "draft — needs native review" "C or ⇧C" = "C atau ⇧C" "CMYK colour" = "Warna CMYK" "Can view" = "Dapat melihat" +"Check" = "Centang" "Checking the server…" = "Memeriksa server…" +"Chevron" = "Chevron" "Clear from this list" = "Hapus dari daftar ini" "Code or invite link" = "Kode atau link undangan" "Color profile: {name}" = "Profil warna: {name}" @@ -244,7 +247,6 @@ status = "draft — needs native review" "Close tab" = "Tutup tab" "Close version history" = "Tutup riwayat versi" "Close when clicking outside" = "Tutup saat klik di luar" -"Close without saving" = "Tutup tanpa menyimpan" "Code" = "Kode" "Code syntax" = "Sintaks kode" "Collapse" = "Ciutkan" @@ -286,6 +288,7 @@ status = "draft — needs native review" "Connected" = "Terhubung" "Connected to code: {from}" = "Terhubung ke kode: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Koneksi terputus, menghubungkan ulang (percobaan {attempt})…" +"Connector" = "Konektor" "Constrain proportions" = "Kunci proporsi" "Constraints" = "Batasan" "Content" = "Konten" @@ -372,6 +375,7 @@ status = "draft — needs native review" "Description" = "Deskripsi" "Description (optional)" = "Deskripsi (opsional)" "Design" = "Desain" +"Design panel" = "Panel desain" "Design system starter" = "Dasar sistem desain" "Desktop" = "Desktop" "Destination" = "Tujuan" @@ -399,6 +403,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Sebarkan jarak horizontal" "Distribute vertical spacing" = "Sebarkan jarak vertikal" "Divider" = "Pembatas" +"Don't Save" = "Jangan simpan" "Don't truncate" = "Jangan potong" "Done" = "Selesai" "Done editing" = "Selesai mengedit" @@ -431,6 +436,7 @@ status = "draft — needs native review" "Effect settings" = "Pengaturan efek" "Effect styles" = "Gaya efek" "Effects" = "Efek" +"Elbow" = "Siku" "Ellipse" = "Elips" "Ellipses" = "Elips" "Else" = "Jika tidak" @@ -566,6 +572,7 @@ status = "draft — needs native review" "Hanging list" = "Daftar menggantung" "Hanging punctuation" = "Tanda baca menggantung" "Hard light" = "Hard light" +"Heart" = "Hati" "Height" = "Tinggi" "Help" = "Bantuan" "Help and resources" = "Bantuan dan sumber daya" @@ -578,6 +585,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Sembunyikan tips ini (tampilkan lagi lewat Memulai di menu Bantuan)" "Hide when publishing" = "Sembunyikan saat dipublikasikan" "Highlight layers on hover" = "Sorot layer saat hover" +"Highlighter" = "Stabilo" "Highlights" = "Sorotan" "Hold Alt" = "Tahan Alt" "Hold Alt+Z" = "Tahan Alt+Z" @@ -743,6 +751,7 @@ status = "draft — needs native review" "Mark as resolved" = "Tandai selesai" "Mark as unread" = "Tandai belum dibaca" "Mark as unresolved" = "Tandai belum selesai" +"Marker" = "Spidol" "Mask" = "Mask" "Mass · Stiffness · Damping" = "Massa · Kekakuan · Redaman" "Match (empty: the whole name)" = "Cocokkan (kosong: seluruh nama)" @@ -801,6 +810,7 @@ status = "draft — needs native review" "Named instance" = "Instance bernama" "Navigate to" = "Navigasi ke" "Nested instance" = "Instance bersarang" +"New board" = "Papan baru" "New design file" = "File desain baru" "Newest first" = "Terbaru dulu" "Next frame" = "Frame berikutnya" @@ -902,6 +912,7 @@ status = "draft — needs native review" "Open branch" = "Buka branch" "Open file" = "Buka file" "Open files" = "Buka file" +"Open presentations" = "Presentasi yang terbuka" "Open in focus view" = "Buka di tampilan fokus" "Open in new window" = "Buka di jendela baru" "Open invite links in FigCraft" = "Buka link undangan di FigCraft" @@ -937,6 +948,7 @@ status = "draft — needs native review" "Page deleted" = "Halaman dihapus" "Pages" = "Halaman" "Pages added to the document" = "Halaman ditambahkan ke dokumen" +"Pink" = "Merah muda" "Placed on the current page" = "Ditempatkan di halaman saat ini" "Pages: {pages}" = "Halaman: {pages}" "Paint bucket" = "Ember cat" @@ -979,6 +991,7 @@ status = "draft — needs native review" "Plugins" = "Plugin" "Plus darker" = "Plus darker" "Plus lighter" = "Plus lighter" +"Plus one" = "Plus satu" "Point" = "Titik" "Points" = "Titik" "Polygon" = "Poligon" @@ -1027,6 +1040,7 @@ status = "draft — needs native review" "Push" = "Dorong" "Push changes to main component" = "Terapkan perubahan ke main component" "Put text on path" = "Tempatkan teks di path" +"Question" = "Pertanyaan" "R" = "R" "Radial" = "Radial" "Radius" = "Radius" @@ -1126,6 +1140,7 @@ status = "draft — needs native review" "Rotation jitter" = "Variasi rotasi" "Round" = "Bulat" "Rounded" = "Membulat" +"Rounded rectangle" = "Persegi panjang membulat" "Row" = "Baris" "Row gap" = "Jarak baris" "Row sizes" = "Ukuran baris" @@ -1198,6 +1213,7 @@ status = "draft — needs native review" "Shape" = "Bentuk" "Shape builder" = "Shape builder" "Shape tools" = "Alat bentuk" +"Shapes" = "Bentuk" "Share" = "Bagikan" "Share this file" = "Bagikan file ini" "Share with a code" = "Bagikan dengan kode" @@ -1264,10 +1280,13 @@ status = "draft — needs native review" "Space+drag" = "Space+seret" "Spacing" = "Spasi" "Spam, stolen work, abuse…" = "Spam, karya curian, penyalahgunaan…" +"Speech bubble" = "Balon kata" "Splay" = "Bukaan" "Spotlight me" = "Sorot saya" "Spread" = "Sebaran" "Square" = "Persegi" +"Stamp" = "Stempel" +"Stamps" = "Stempel" "Standard" = "Standar" "Standard ligatures" = "Ligatur standar" "Star" = "Bintang" @@ -1283,12 +1302,16 @@ status = "draft — needs native review" "Starting to share…" = "Memulai berbagi…" "Status" = "Status" "Sticky" = "Menempel" +"Sticky note" = "Catatan tempel" +"Sticky note colors" = "Warna catatan tempel" +"Sticky notes, shapes and connectors" = "Catatan tempel, bentuk, dan konektor" "Stop" = "Hentikan" "Stop following {name}" = "Berhenti mengikuti {name}" "Stop session" = "Hentikan sesi" "Stop sharing" = "Berhenti berbagi" "Stop spotlighting" = "Hentikan sorotan" "Stop {n} {hex}" = "Titik {n} {hex}" +"Straight" = "Lurus" "Stretch" = "Regangkan" "Strikethrough" = "Coret" "String" = "String" @@ -1333,6 +1356,7 @@ status = "draft — needs native review" "Text" = "Teks" "Text align" = "Perataan teks" "Text content" = "Konten teks" +"Text size" = "Ukuran teks" "Text style" = "Gaya teks" "Text styles" = "Gaya teks" "Texts" = "Teks" @@ -1363,6 +1387,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Server ini terbuka untuk publik: tidak perlu kunci." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Server ini memerlukan kuncinya: tambahkan ?key=… ke alamat (`figcraft-server key` menampilkannya di mesin server)." "This server takes files only with its key: see Advanced." = "Server ini hanya menerima file dengan kuncinya: lihat Lanjutan." +"Thumbs up" = "Jempol" "Tidy up" = "Rapikan" "Tile" = "Ubin" "Tile type" = "Jenis ubin" @@ -1379,6 +1404,7 @@ status = "draft — needs native review" "Top left radius" = "Radius kiri atas" "Top right radius" = "Radius kanan atas" "Transform" = "Transformasi" +"Triangle" = "Segitiga" "Triangle arrow" = "Panah segitiga" "Trigger" = "Pemicu" "True" = "Benar" @@ -1403,7 +1429,7 @@ status = "draft — needs native review" "Unread" = "Belum dibaca" "Unresolve" = "Buka kembali" "Unsaved changes" = "Perubahan belum disimpan" -"Unsaved changes in {names}. Close the window and lose them?" = "Perubahan belum disimpan di {names}. Tutup jendela dan buang perubahan?" +"Unsaved changes in {names}. Save them before closing?" = "Perubahan belum disimpan di {names}. Simpan sebelum menutup?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Perubahan belum disimpan: simpan dulu ({save}), atau tutup tab dengan {alt}-klik untuk membuangnya" "Unsaved changes: save first, or close again with {key} to discard" = "Perubahan belum disimpan: simpan dulu, atau tutup lagi dengan {key} untuk membuangnya" "Up" = "Atas" @@ -1733,6 +1759,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Bulatkan ke piksel" "arrange.spacing" = "Spasi" "arrange.tidy" = "Rapikan" +"board.connector" = "Konektor" +"board.ink" = "Goresan spidol" +"board.label" = "Label konektor" +"board.nextSticky" = "Catatan tempel berikutnya" +"board.setColor" = "Warna papan" +"board.setStyle" = "Gaya papan" +"board.shape" = "Bentuk dengan teks" +"board.stamp" = "Stempel" +"board.sticky" = "Catatan tempel" "bool.exclude" = "Kecualikan pilihan" "bool.flatten" = "Satukan boolean" "bool.intersect" = "Iriskan pilihan" @@ -1858,6 +1893,7 @@ status = "draft — needs native review" "file.import" = "Impor" "file.moveTab" = "Pindahkan tab" "file.new" = "File desain baru" +"file.newBoard" = "Papan baru" "file.newFromTemplate" = "Baru dari templat" "file.open" = "Buka" "file.openRecent" = "Buka terbaru" @@ -2124,10 +2160,14 @@ status = "draft — needs native review" "text.weightUp" = "Tambah ketebalan font" "tool.arrow" = "Panah" "tool.comment" = "Komentar" +"tool.connector" = "Konektor" "tool.ellipse" = "Elips" +"tool.eraser" = "Penghapus" "tool.frame" = "Frame" "tool.hand" = "Tangan" +"tool.highlighter" = "Stabilo" "tool.line" = "Garis" +"tool.marker" = "Spidol" "tool.move" = "Pindahkan" "tool.pen" = "Pena" "tool.pencil" = "Pensil" @@ -2139,9 +2179,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Titik acuan skala" "tool.section" = "Section" "tool.set" = "Pilih alat" +"tool.shape" = "Bentuk" "tool.shapeBuilder" = "Shape builder" "tool.slice" = "Slice" +"tool.stamp" = "Stempel" "tool.star" = "Bintang" +"tool.sticky" = "Catatan tempel" "tool.text" = "Teks" "transform.move" = "Pindahkan" "transform.moveTo" = "Pindahkan ke" @@ -2203,11 +2246,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Alat edit vector" "vector.edit.transform" = "Transformasi titik" "vector.edit.widthPoint" = "Titik lebar" +"vector.fillRule" = "Atur aturan isian" "vector.flatten" = "Satukan" "vector.join" = "Gabungkan pilihan" "vector.offset" = "Offset path" "vector.outlineStroke" = "Outline stroke" "vector.reverse" = "Balik arah path" +"vector.reverseLoop" = "Balik loop" "vector.simplify" = "Sederhanakan vector" "vector.smoothJoin" = "Gabungkan pilihan dengan halus" "version.autosave" = "Simpan versi otomatis" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index c42790f3..d7ee7287 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -126,6 +126,7 @@ status = "draft — needs native review" "Archived" = "アーカイブ済み" "Arrange" = "配置" "Arrow" = "矢印" +"Arrow at the end" = "末端に矢印" "As typed" = "入力どおり" "Asking the server…" = "サーバーに問い合わせ中…" "Assets" = "アセット" @@ -199,7 +200,9 @@ status = "draft — needs native review" "C or ⇧C" = "C または ⇧C" "CMYK colour" = "CMYK カラー" "Can view" = "閲覧可" +"Check" = "チェック" "Checking the server…" = "サーバーを確認中…" +"Chevron" = "シェブロン" "Clear from this list" = "このリストから消去" "Code or invite link" = "コードまたは招待リンク" "Color profile: {name}" = "カラープロファイル:{name}" @@ -245,7 +248,6 @@ status = "draft — needs native review" "Close tab" = "タブを閉じる" "Close version history" = "バージョン履歴を閉じる" "Close when clicking outside" = "外側をクリックしたら閉じる" -"Close without saving" = "保存せずに閉じる" "Code" = "コード" "Code syntax" = "コード構文" "Collapse" = "折りたたむ" @@ -273,6 +275,7 @@ status = "draft — needs native review" "Commits" = "コミット" "Company" = "会社" "Compare changes" = "変更を比較" +"Compare with" = "比較対象" "Compatibility notes" = "互換性に関する注記" "Completed" = "完了" "Component" = "コンポーネント" @@ -288,6 +291,7 @@ status = "draft — needs native review" "Connected" = "接続済み" "Connected to code: {from}" = "コードに接続済み:{from}" "Connection lost, reconnecting (attempt {attempt})…" = "接続が切断。再接続中({attempt} 回目)…" +"Connector" = "コネクタ" "Constrain proportions" = "縦横比を固定" "Constraints" = "制約" "Content" = "コンテンツ" @@ -375,6 +379,7 @@ status = "draft — needs native review" "Description" = "説明" "Description (optional)" = "説明(任意)" "Design" = "デザイン" +"Design panel" = "デザインパネル" "Design system starter" = "デザインシステムの雛形" "Desktop" = "デスクトップ" "Destination" = "遷移先" @@ -386,6 +391,7 @@ status = "draft — needs native review" "Detach variable" = "変数を切り離す" "Details" = "詳細" "Dev Mode" = "開発モード" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "開発モードは検査専用です。編集するにはデザイン (Shift+D) に切り替えてください" "Dev resources" = "開発リソース" "Development" = "開発" "Device" = "デバイス" @@ -402,6 +408,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "水平方向に等間隔に分布" "Distribute vertical spacing" = "垂直方向に等間隔に分布" "Divider" = "区切り線" +"Don't Save" = "保存しない" "Don't truncate" = "切り詰めない" "Done" = "完了" "Done editing" = "編集を完了" @@ -434,6 +441,7 @@ status = "draft — needs native review" "Effect settings" = "エフェクトの設定" "Effect styles" = "エフェクトスタイル" "Effects" = "エフェクト" +"Elbow" = "直角" "Ellipse" = "楕円" "Ellipses" = "楕円" "Else" = "それ以外" @@ -570,6 +578,7 @@ status = "draft — needs native review" "Hanging list" = "ぶら下げリスト" "Hanging punctuation" = "ぶら下げ句読点" "Hard light" = "ハードライト" +"Heart" = "ハート" "Height" = "高さ" "Help" = "ヘルプ" "Help and resources" = "ヘルプとリソース" @@ -582,6 +591,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "ヒントを非表示(ヘルプメニューの「使い始めるには」で再表示)" "Hide when publishing" = "公開時に非表示" "Highlight layers on hover" = "ホバー時にレイヤーをハイライト" +"Highlighter" = "蛍光ペン" "Highlights" = "ハイライト" "Hold Alt" = "Alt を押したまま" "Hold Alt+Z" = "Alt+Z を押したまま" @@ -748,6 +758,7 @@ status = "draft — needs native review" "Mark as resolved" = "解決済みにする" "Mark as unread" = "未読にする" "Mark as unresolved" = "未解決にする" +"Marker" = "マーカー" "Mask" = "マスク" "Mass · Stiffness · Damping" = "質量 · 剛性 · 減衰" "Match (empty: the whole name)" = "一致(空欄:名前全体)" @@ -806,6 +817,7 @@ status = "draft — needs native review" "Named instance" = "名前付きインスタンス" "Navigate to" = "移動先" "Nested instance" = "ネストされたインスタンス" +"New board" = "新規ボード" "New design file" = "新規デザインファイル" "Newest first" = "新しい順" "Next frame" = "次のフレーム" @@ -870,6 +882,7 @@ status = "draft — needs native review" "Not read yet" = "未読" "Not shared" = "共有していない" "Note {n}" = "メモ {n}" +"Nothing changed since this version." = "このバージョンから変更なし。" "Nothing changed since this was marked ready for dev." = "開発準備完了にしてから変更なし。" "Nothing here yet. Set a frame's status to list it." = "まだ何もない。フレームのステータスを設定すると一覧に表示される。" "Nothing online yet. Use Share on a file to put it online and it is listed here." = "オンラインのファイルはまだない。ファイルの「共有」からオンラインにすると、ここに表示される。" @@ -907,6 +920,7 @@ status = "draft — needs native review" "Open branch" = "ブランチを開く" "Open file" = "ファイルを開く" "Open files" = "開いているファイル" +"Open presentations" = "開いているプレゼンテーション" "Open in focus view" = "フォーカスビューで開く" "Open in new window" = "新しいウィンドウで開く" "Open invite links in FigCraft" = "招待リンクを FigCraft で開く" @@ -942,6 +956,7 @@ status = "draft — needs native review" "Page deleted" = "ページは削除済み" "Pages" = "ページ" "Pages added to the document" = "ページをドキュメントに追加した" +"Pink" = "ピンク" "Placed on the current page" = "現在のページに配置した" "Pages: {pages}" = "ページ:{pages}" "Paint bucket" = "塗りつぶし" @@ -985,6 +1000,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "プラグインは FigCraft のサンドボックス内で、ファイル、ネットワーク、時計なしで実行される。信頼できるプラグインのみをインストールすること。" "Plus darker" = "プラス(暗)" "Plus lighter" = "プラス(明)" +"Plus one" = "プラスワン" "Point" = "ポイント" "Points" = "ポイント" "Polygon" = "多角形" @@ -1032,6 +1048,7 @@ status = "draft — needs native review" "Push" = "プッシュ" "Push changes to main component" = "変更をメインコンポーネントにプッシュ" "Put text on path" = "テキストをパスに沿わせる" +"Question" = "質問" "R" = "R" "Radial" = "放射状" "Radius" = "半径" @@ -1077,6 +1094,7 @@ status = "draft — needs native review" "Removed" = "削除" "Removed from the server" = "サーバーから削除済み" "Removed since it was marked" = "マーク後に削除" +"Removed since this version" = "このバージョン以降に削除" "Rename" = "名前を変更" "Rename duplicated layers" = "複製したレイヤーの名前を変更" "Rename folder" = "フォルダー名を変更" @@ -1132,6 +1150,7 @@ status = "draft — needs native review" "Rotation jitter" = "回転のばらつき" "Round" = "丸型" "Rounded" = "丸型" +"Rounded rectangle" = "角丸長方形" "Row" = "行" "Row gap" = "行の間隔" "Row sizes" = "行のサイズ" @@ -1205,6 +1224,7 @@ status = "draft — needs native review" "Shape" = "シェイプ" "Shape builder" = "シェイプビルダー" "Shape tools" = "シェイプツール" +"Shapes" = "シェイプ" "Share" = "共有" "Share this file" = "このファイルを共有" "Share with a code" = "コードで共有" @@ -1271,10 +1291,13 @@ status = "draft — needs native review" "Space+drag" = "Space+ドラッグ" "Spacing" = "間隔" "Spam, stolen work, abuse…" = "スパム、盗用、迷惑行為…" +"Speech bubble" = "吹き出し" "Splay" = "広がり" "Spotlight me" = "自分をスポットライト" "Spread" = "スプレッド" "Square" = "正方形" +"Stamp" = "スタンプ" +"Stamps" = "スタンプ" "Standard" = "標準" "Standard ligatures" = "標準合字" "Star" = "星" @@ -1291,12 +1314,16 @@ status = "draft — needs native review" "Starting to share…" = "共有を開始中…" "Status" = "ステータス" "Sticky" = "固定" +"Sticky note" = "付箋" +"Sticky note colors" = "付箋の色" +"Sticky notes, shapes and connectors" = "付箋、シェイプ、コネクタ" "Stop" = "停止" "Stop following {name}" = "{name} のフォローを停止" "Stop session" = "セッションを停止" "Stop sharing" = "共有を停止" "Stop spotlighting" = "スポットライトを停止" "Stop {n} {hex}" = "分岐点 {n} {hex}" +"Straight" = "直線" "Stretch" = "伸縮" "Strikethrough" = "取り消し線" "String" = "文字列" @@ -1341,6 +1368,7 @@ status = "draft — needs native review" "Text" = "テキスト" "Text align" = "テキストの配置" "Text content" = "テキストの内容" +"Text size" = "テキストサイズ" "Text style" = "テキストスタイル" "Text styles" = "テキストスタイル" "Texts" = "テキスト" @@ -1371,6 +1399,9 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "このサーバーは一般公開されている:キーは不要。" "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "このサーバーにはキーが必要:アドレスに ?key=… を追加(サーバーのマシンで `figcraft-server key` を実行すると表示)。" "This server takes files only with its key: see Advanced." = "このサーバーはキーがないとファイルを受け付けない:「詳細設定」を参照。" +"Thumbs up" = "いいね" +"This version can't be read." = "このバージョンを読み込めません。" +"This version doesn't have it: all of it is new." = "このバージョンにはありません。すべて新規です。" "Tidy up" = "整列" "Tile" = "タイル" "Tile type" = "タイルの種類" @@ -1387,6 +1418,7 @@ status = "draft — needs native review" "Top left radius" = "左上の半径" "Top right radius" = "右上の半径" "Transform" = "変形" +"Triangle" = "三角形" "Triangle arrow" = "三角矢印" "Trigger" = "トリガー" "True" = "true" @@ -1411,7 +1443,7 @@ status = "draft — needs native review" "Unread" = "未読" "Unresolve" = "未解決に戻す" "Unsaved changes" = "未保存の変更" -"Unsaved changes in {names}. Close the window and lose them?" = "{names} に未保存の変更がある。ウィンドウを閉じて変更を破棄する?" +"Unsaved changes in {names}. Save them before closing?" = "{names} に未保存の変更がある。閉じる前に保存する?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "未保存の変更:先に保存する({save})か、{alt}-クリックでタブを閉じて破棄" "Unsaved changes: save first, or close again with {key} to discard" = "未保存の変更:先に保存するか、もう一度 {key} で閉じて破棄" "Up" = "上" @@ -1476,11 +1508,13 @@ status = "draft — needs native review" "Websites" = "Webサイト" "Wedge" = "くさび" "Weight" = "太さ" +"What changed since a saved version" = "保存したバージョンからの変更" "What changed since it was marked ready for dev" = "開発準備完了にしてからの変更内容" "What is wrong with this file? The server's operator gets your report." = "このファイルの何が問題ですか? 報告はサーバーの運営者に届く。" "What it's for" = "用途" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "変更前の状態は不明:FigCraft が開発準備完了フレームのコピーを保存するようになる前に変更された。変更を解決する(または再度準備完了にする)と、今後はそこから比較する。" "What you try here changes this preview and the code below, not the file" = "ここでの操作はプレビューと下のコードに反映。ファイルは変更されない" +"When marked ready" = "準備完了にした時点" "While hovering" = "ホバー中" "While pressing" = "押している間" "White" = "白" @@ -1744,6 +1778,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "ピクセルに丸める" "arrange.spacing" = "間隔" "arrange.tidy" = "整列" +"board.connector" = "コネクタ" +"board.ink" = "マーカーの線" +"board.label" = "コネクタのラベル" +"board.nextSticky" = "次の付箋" +"board.setColor" = "ボードの色" +"board.setStyle" = "ボードのスタイル" +"board.shape" = "テキスト付きシェイプ" +"board.stamp" = "スタンプ" +"board.sticky" = "付箋" "bool.exclude" = "選択範囲を除外" "bool.flatten" = "ブーリアンを平坦化" "bool.intersect" = "選択範囲を交差" @@ -1870,6 +1913,7 @@ status = "draft — needs native review" "file.import" = "インポート" "file.moveTab" = "タブを移動" "file.new" = "新規デザインファイル" +"file.newBoard" = "新規ボード" "file.newFromTemplate" = "テンプレートから新規作成" "file.open" = "開く" "file.openRecent" = "最近使ったファイルを開く" @@ -2139,10 +2183,14 @@ status = "draft — needs native review" "text.weightUp" = "フォントの太さを太く" "tool.arrow" = "矢印" "tool.comment" = "コメント" +"tool.connector" = "コネクタ" "tool.ellipse" = "楕円" +"tool.eraser" = "消しゴム" "tool.frame" = "フレーム" "tool.hand" = "手のひら" +"tool.highlighter" = "蛍光ペン" "tool.line" = "線" +"tool.marker" = "マーカー" "tool.move" = "移動" "tool.pen" = "ペン" "tool.pencil" = "鉛筆" @@ -2154,9 +2202,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "拡大縮小の基準点" "tool.section" = "セクション" "tool.set" = "ツールを選択" +"tool.shape" = "シェイプ" "tool.shapeBuilder" = "シェイプビルダー" "tool.slice" = "スライス" +"tool.stamp" = "スタンプ" "tool.star" = "星" +"tool.sticky" = "付箋" "tool.text" = "テキスト" "transform.move" = "移動" "transform.moveTo" = "指定位置へ移動" @@ -2218,11 +2269,13 @@ status = "draft — needs native review" "vector.edit.tool" = "ベクター編集ツール" "vector.edit.transform" = "ポイントを変形" "vector.edit.widthPoint" = "幅のポイント" +"vector.fillRule" = "塗りつぶし規則を設定" "vector.flatten" = "平坦化" "vector.join" = "選択範囲を連結" "vector.offset" = "パスのオフセット" "vector.outlineStroke" = "線のアウトライン化" "vector.reverse" = "パスの方向を反転" +"vector.reverseLoop" = "ループを反転" "vector.simplify" = "ベクターを単純化" "vector.smoothJoin" = "選択範囲を滑らかに連結" "version.autosave" = "自動保存バージョン" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 5b73b4fb..42f78be6 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -128,6 +128,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Archived" = "დაარქივებულია" "Arrange" = "დალაგება" "Arrow" = "ისარი" +"Arrow at the end" = "ისარი ბოლოში" "As typed" = "როგორც აიკრიფა" "Asking the server…" = "სერვერთან მოთხოვნა…" "Assets" = "რესურსები" @@ -201,12 +202,15 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "C or ⇧C" = "C ან ⇧C" "CMYK colour" = "CMYK ფერი" "Can view" = "შეუძლია ნახვა" +"Check" = "ნიშნული" "Checking the fonts installed on this computer…" = "ამ კომპიუტერზე დაინსტალირებული შრიფტების შემოწმება…" "Checking the server…" = "სერვერის შემოწმება…" +"Chevron" = "შევრონი" "Clear from this list" = "ამ სიიდან ამოღება" "Code or invite link" = "კოდი ან მოსაწვევი ბმული" "Color profile: {name}" = "ფერის პროფილი: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "ფერისა და ტექსტის სტილები, ღია და მუქი ცვლადები, ღილაკები, შეყვანის ველები და ბარათები" +"Connector" = "შემაერთებელი" "Convert to frame" = "ჩარჩოდ გარდაქმნა" "Convert to section" = "სექციად გარდაქმნა" "CSS custom properties…" = "CSS-ის მორგებული თვისებები…" @@ -251,7 +255,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Close tab" = "ჩანართის დახურვა" "Close version history" = "ვერსიების ისტორიის დახურვა" "Close when clicking outside" = "დახურვა გარეთ დაწკაპუნებისას" -"Close without saving" = "დახურვა შენახვის გარეშე" "Code" = "კოდი" "Code syntax" = "კოდის სინტაქსი" "Collapse" = "ჩაკეცვა" @@ -380,6 +383,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Description" = "აღწერა" "Description (optional)" = "აღწერა (არასავალდებულო)" "Design" = "დიზაინი" +"Design panel" = "დიზაინის პანელი" "Design system starter" = "დიზაინ-სისტემის საწყისი ნაკრები" "Desktop" = "დესკტოპი" "Destination" = "დანიშნულება" @@ -407,6 +411,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Distribute horizontal spacing" = "ჰორიზონტალური შუალედების განაწილება" "Distribute vertical spacing" = "ვერტიკალური შუალედების განაწილება" "Divider" = "გამყოფი" +"Don't Save" = "არ შეინახო" "Don't truncate" = "შეკვეცის გარეშე" "Done" = "მზადაა" "Done editing" = "რედაქტირების დასრულება" @@ -442,6 +447,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Effect settings" = "ეფექტის პარამეტრები" "Effect styles" = "ეფექტის სტილები" "Effects" = "ეფექტები" +"Elbow" = "კუთხოვანი" "Ellipse" = "ელიფსი" "Ellipses" = "ელიფსები" "Else" = "სხვა შემთხვევაში" @@ -578,6 +584,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Hanging list" = "გამოწეული სია" "Hanging punctuation" = "გამოწეული პუნქტუაცია" "Hard light" = "მკვეთრი სინათლე" +"Heart" = "გული" "Height" = "სიმაღლე" "Help" = "დახმარება" "Help and resources" = "დახმარება და რესურსები" @@ -590,6 +597,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Hide these tips (Getting started in the Help menu shows them again)" = "ამ რჩევების დამალვა („პირველი ნაბიჯები“ დახმარების მენიუში ისევ აჩვენებს მათ)" "Hide when publishing" = "გამოქვეყნებისას დამალვა" "Highlight layers on hover" = "შრეების გამოკვეთა მაჩვენებლის მიტანისას" +"Highlighter" = "ხაზგასმის მარკერი" "Highlights" = "ღია ტონები" "Hold Alt" = "Alt-ის დაჭერა" "Hold Alt+Z" = "Alt+Z-ის დაჭერა" @@ -756,6 +764,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Mark as resolved" = "გადაწყვეტილად მარკირება" "Mark as unread" = "წაუკითხავად მარკირება" "Mark as unresolved" = "გადაუწყვეტლად მარკირება" +"Marker" = "მარკერი" "Mask" = "ნიღაბი" "Mass · Stiffness · Damping" = "მასა · სიხისტე · მილევა" "Match (empty: the whole name)" = "დამთხვევა (ცარიელი: მთელი სახელი)" @@ -815,6 +824,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Named instance" = "დასახელებული ინსტანსი" "Navigate to" = "გადასვლა" "Nested instance" = "ჩადგმული ინსტანსი" +"New board" = "ახალი დაფა" "New design file" = "ახალი დიზაინის ფაილი" "Newest first" = "ჯერ უახლესი" "Next or previous control in the focused region" = "ფოკუსირებულ არეში შემდეგი ან წინა მართვის ელემენტი" @@ -955,6 +965,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Page" = "გვერდი" "Pages" = "გვერდები" "Pages added to the document" = "გვერდები დაემატა დოკუმენტს" +"Pink" = "ვარდისფერი" "Placed on the current page" = "განთავსდა მიმდინარე გვერდზე" "Pages: {pages}" = "გვერდი: {pages}" "Paint bucket" = "საღებავის ვედრო" @@ -999,6 +1010,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Plugins & widgets" = "პლაგინები და ვიჯეტები" "Plus darker" = "პლუს მუქი" "Plus lighter" = "პლუს ღია" +"Plus one" = "პლუს ერთი" "Point" = "წერტილი" "Points" = "წერტილები" "Polygon" = "მრავალკუთხედი" @@ -1047,6 +1059,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Push" = "ბიძგი" "Push changes to main component" = "ცვლილებების მთავარ კომპონენტში გადაგზავნა" "Put text on path" = "ტექსტის ტრაექტორიაზე განთავსება" +"Question" = "კითხვა" "R" = "R" "Radial" = "რადიალური" "Radius" = "რადიუსი" @@ -1147,6 +1160,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Rotation jitter" = "შემობრუნების ცვალებადობა" "Round" = "მომრგვალებული" "Rounded" = "მომრგვალებული" +"Rounded rectangle" = "მომრგვალებული მართკუთხედი" "Row" = "სტრიქონი" "Row gap" = "სტრიქონებს შორის შუალედი" "Row sizes" = "სტრიქონების ზომები" @@ -1222,6 +1236,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Shape" = "ფიგურა" "Shape builder" = "ფიგურების კონსტრუქტორი" "Shape tools" = "ფიგურის ხელსაწყოები" +"Shapes" = "ფიგურები" "Share" = "გაზიარება" "Share this file" = "ამ ფაილის გაზიარება" "Share with a code" = "კოდით გაზიარება" @@ -1288,10 +1303,13 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Space+drag" = "Space+გადათრევა" "Spacing" = "ინტერვალი" "Spam, stolen work, abuse…" = "სპამი, მოპარული ნამუშევარი, შეურაცხყოფა…" +"Speech bubble" = "სიტყვის ბუშტი" "Splay" = "გაშლილობა" "Spotlight me" = "ჩემი ხედის ჩვენება ყველასთვის" "Spread" = "გაფართოება" "Square" = "კვადრატი" +"Stamp" = "შტამპი" +"Stamps" = "შტამპები" "Standard" = "სტანდარტული" "Standard ligatures" = "სტანდარტული ლიგატურები" "Star" = "ვარსკვლავი" @@ -1307,12 +1325,16 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Starting to share…" = "გაზიარების დაწყება…" "Status" = "სტატუსი" "Sticky" = "მიმაგრებული" +"Sticky note" = "სტიკერი" +"Sticky note colors" = "სტიკერების ფერები" +"Sticky notes, shapes and connectors" = "სტიკერები, ფიგურები და შემაერთებლები" "Stop" = "შეწყვეტა" "Stop following {name}" = "{name}-ის მიყოლის შეწყვეტა" "Stop session" = "სესიის შეწყვეტა" "Stop sharing" = "გაზიარების შეწყვეტა" "Stop spotlighting" = "ჩემი ხედის ჩვენების შეწყვეტა" "Stop {n} {hex}" = "გადასვლის წერტილი {n} {hex}" +"Straight" = "სწორი" "Stretch" = "გაწელვა" "Strikethrough" = "გადახაზული" "String" = "ტექსტი" @@ -1357,6 +1379,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Text" = "ტექსტი" "Text align" = "ტექსტის სწორება" "Text content" = "ტექსტის შიგთავსი" +"Text size" = "ტექსტის ზომა" "Text style" = "ტექსტის სტილი" "Text styles" = "ტექსტის სტილები" "Texts" = "ტექსტები" @@ -1391,6 +1414,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "This server is open to the public: no key needed." = "ეს სერვერი საჯაროა: გასაღები საჭირო არ არის." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "ამ სერვერს გასაღები სჭირდება: მისამართს დაუმატეთ ?key=… (`figcraft-server key` გამოიტანს მას სერვერის კომპიუტერზე)." "This server takes files only with its key: see Advanced." = "ეს სერვერი ფაილებს მხოლოდ გასაღებით იღებს: იხილეთ „დამატებითი“." +"Thumbs up" = "ცერი ზემოთ" "Tidy up" = "მოწესრიგება" "Tile" = "ფილებად" "Tile type" = "ფილის ტიპი" @@ -1407,6 +1431,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Top left radius" = "ზედა მარცხენა რადიუსი" "Top right radius" = "ზედა მარჯვენა რადიუსი" "Transform" = "გარდაქმნა" +"Triangle" = "სამკუთხედი" "Triangle arrow" = "სამკუთხა ისარი" "Trigger" = "გამშვები" "True" = "ჭეშმარიტი" @@ -1432,7 +1457,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Unread" = "წაუკითხავი" "Unresolve" = "ხელახლა გახსნა" "Unsaved changes" = "შეუნახავი ცვლილებები" -"Unsaved changes in {names}. Close the window and lose them?" = "შეუნახავი ცვლილებები: {names}. დაიხუროს ფანჯარა და დაიკარგოს ისინი?" +"Unsaved changes in {names}. Save them before closing?" = "შეუნახავი ცვლილებები: {names}. შევინახოთ დახურვამდე?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "შეუნახავი ცვლილებები: ჯერ შეინახეთ ({save}) ან გასაუქმებლად დახურეთ ჩანართი {alt}+დაწკაპუნებით" "Unsaved changes: save first, or close again with {key} to discard" = "შეუნახავი ცვლილებები: ჯერ შეინახეთ ან გასაუქმებლად ხელახლა დახურეთ {key}-ით" "Up" = "ზემოთ" @@ -1768,6 +1793,15 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "arrange.roundToPixels" = "პიქსელებამდე დამრგვალება" "arrange.spacing" = "ინტერვალი" "arrange.tidy" = "მოწესრიგება" +"board.connector" = "შემაერთებელი" +"board.ink" = "მარკერის შტრიხი" +"board.label" = "შემაერთებლის წარწერა" +"board.nextSticky" = "შემდეგი სტიკერი" +"board.setColor" = "დაფის ფერი" +"board.setStyle" = "დაფის სტილი" +"board.shape" = "ფიგურა ტექსტით" +"board.stamp" = "შტამპი" +"board.sticky" = "სტიკერი" "bool.exclude" = "მონიშნულის გამორიცხვა" "bool.flatten" = "ლოგიკური ოპერაციის გაბრტყელება" "bool.intersect" = "მონიშნულის გადაკვეთა" @@ -1893,6 +1927,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "file.exportSvg" = "მონიშნულის SVG-ად ექსპორტირება" "file.import" = "იმპორტი" "file.new" = "ახალი დიზაინის ფაილი" +"file.newBoard" = "ახალი დაფა" "file.newFromTemplate" = "შაბლონიდან შექმნა" "file.open" = "გახსნა" "file.openRecent" = "ბოლოს გახსნილი ფაილების გახსნა" @@ -2163,10 +2198,14 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "text.weightUp" = "შრიფტის სისქის გაზრდა" "tool.arrow" = "ისარი" "tool.comment" = "კომენტარი" +"tool.connector" = "შემაერთებელი" "tool.ellipse" = "ელიფსი" +"tool.eraser" = "საშლელი" "tool.frame" = "ჩარჩო" "tool.hand" = "ხელი" +"tool.highlighter" = "ხაზგასმის მარკერი" "tool.line" = "ხაზი" +"tool.marker" = "მარკერი" "tool.move" = "გადაადგილება" "tool.pen" = "კალამი" "tool.pencil" = "ფანქარი" @@ -2178,9 +2217,12 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "tool.scaleAnchor" = "მასშტაბირების საყრდენი წერტილი" "tool.section" = "სექცია" "tool.set" = "ხელსაწყოს არჩევა" +"tool.shape" = "ფიგურები" "tool.shapeBuilder" = "ფიგურების კონსტრუქტორი" "tool.slice" = "ნაჭერი" +"tool.stamp" = "შტამპი" "tool.star" = "ვარსკვლავი" +"tool.sticky" = "სტიკერი" "tool.text" = "ტექსტი" "transform.move" = "გადაადგილება" "transform.moveTo" = "გადატანა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index b33b36b9..66eb54c2 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -118,6 +118,7 @@ status = "draft — needs native review" "Archived" = "보관됨" "Arrange" = "배치" "Arrow" = "화살표" +"Arrow at the end" = "끝에 화살표" "As typed" = "입력한 대로" "Asking the server…" = "서버에 확인하는 중…" "Assets" = "에셋" @@ -189,7 +190,9 @@ status = "draft — needs native review" "C or ⇧C" = "C 또는 ⇧C" "CMYK colour" = "CMYK 색상" "Can view" = "보기 가능" +"Check" = "체크" "Checking the server…" = "서버 확인 중…" +"Chevron" = "셰브런" "Clear from this list" = "이 목록에서 지우기" "Code or invite link" = "코드 또는 초대 링크" "Color profile: {name}" = "색상 프로필: {name}" @@ -235,7 +238,6 @@ status = "draft — needs native review" "Close tab" = "탭 닫기" "Close version history" = "버전 기록 닫기" "Close when clicking outside" = "바깥쪽을 클릭하면 닫기" -"Close without saving" = "저장하지 않고 닫기" "Code" = "코드" "Code syntax" = "코드 구문" "Collapse" = "접기" @@ -262,6 +264,7 @@ status = "draft — needs native review" "Commits" = "커밋" "Company" = "회사" "Compare changes" = "변경 사항 비교" +"Compare with" = "비교 대상" "Compatibility notes" = "호환성 참고 사항" "Completed" = "완료됨" "Component" = "컴포넌트" @@ -277,6 +280,7 @@ status = "draft — needs native review" "Connected" = "연결됨" "Connected to code: {from}" = "코드에 연결됨: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "연결이 끊어짐. 다시 연결하는 중({attempt}번째 시도)…" +"Connector" = "커넥터" "Constrain proportions" = "비율 고정" "Constraints" = "제약" "Content" = "콘텐츠" @@ -360,6 +364,7 @@ status = "draft — needs native review" "Description" = "설명" "Description (optional)" = "설명(선택 사항)" "Design" = "디자인" +"Design panel" = "디자인 패널" "Desktop" = "데스크톱" "Destination" = "대상" "Detach alias" = "별칭 분리" @@ -370,6 +375,7 @@ status = "draft — needs native review" "Detach variable" = "변수 분리" "Details" = "세부 정보" "Dev Mode" = "개발 모드" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "개발 모드는 검사 전용입니다. 편집하려면 디자인(Shift+D)으로 전환하세요" "Dev resources" = "개발 리소스" "Development" = "개발" "Device" = "기기" @@ -386,6 +392,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "수평 간격 균등 분포" "Distribute vertical spacing" = "수직 간격 균등 분포" "Divider" = "구분선" +"Don't Save" = "저장 안 함" "Don't truncate" = "자르지 않음" "Done" = "완료" "Done editing" = "편집 완료" @@ -416,6 +423,7 @@ status = "draft — needs native review" "Effect settings" = "효과 설정" "Effect styles" = "효과 스타일" "Effects" = "효과" +"Elbow" = "꺾은선" "Ellipse" = "타원" "Ellipses" = "타원" "Else" = "그 외" @@ -546,6 +554,7 @@ status = "draft — needs native review" "Hanging list" = "내어쓰기 목록" "Hanging punctuation" = "문장 부호 내어쓰기" "Hard light" = "하드 라이트" +"Heart" = "하트" "Height" = "높이" "Help" = "도움말" "Help and resources" = "도움말 및 리소스" @@ -556,6 +565,7 @@ status = "draft — needs native review" "Hide from publishing" = "게시에서 숨기기" "Hide section contents" = "섹션 콘텐츠 숨기기" "Highlight layers on hover" = "마우스를 올리면 레이어 강조" +"Highlighter" = "형광펜" "Highlights" = "밝은 영역" "Hold Alt" = "Alt 누른 채로" "Hold Alt+Z" = "Alt+Z 누른 채로" @@ -711,6 +721,7 @@ status = "draft — needs native review" "Mark as resolved" = "해결됨으로 표시" "Mark as unread" = "읽지 않음으로 표시" "Mark as unresolved" = "미해결로 표시" +"Marker" = "마커" "Mask" = "마스크" "Mass · Stiffness · Damping" = "질량 · 강성 · 감쇠" "Match (empty: the whole name)" = "일치(비우면 이름 전체)" @@ -767,6 +778,7 @@ status = "draft — needs native review" "Named instance" = "명명된 인스턴스" "Navigate to" = "이동 대상" "Nested instance" = "중첩된 인스턴스" +"New board" = "새 보드" "New design file" = "새 디자인 파일" "Next frame" = "다음 프레임" "Next or previous control in the focused region" = "포커스된 영역의 다음 또는 이전 컨트롤" @@ -829,6 +841,7 @@ status = "draft — needs native review" "Not read yet" = "아직 읽지 않음" "Not shared" = "공유 안 함" "Note {n}" = "메모 {n}" +"Nothing changed since this version." = "이 버전 이후 변경된 내용 없음." "Nothing changed since this was marked ready for dev." = "개발 준비 완료로 표시된 뒤 변경된 내용 없음." "Nothing here yet. Set a frame's status to list it." = "아직 아무것도 없음. 프레임의 상태를 설정하면 목록에 표시됨." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "아직 온라인에 올린 파일이 없음. 파일에서 공유를 사용해 온라인에 올리면 여기에 표시됨." @@ -862,6 +875,7 @@ status = "draft — needs native review" "Open branch" = "브랜치 열기" "Open file" = "파일 열기" "Open files" = "열린 파일" +"Open presentations" = "열린 프레젠테이션" "Open in focus view" = "포커스 보기로 열기" "Open in new window" = "새 창에서 열기" "Open invite links in FigCraft" = "초대 링크를 FigCraft에서 열기" @@ -896,6 +910,7 @@ status = "draft — needs native review" "Page deleted" = "페이지가 삭제됨" "Pages" = "페이지" "Pages added to the document" = "문서에 페이지 추가됨" +"Pink" = "분홍" "Placed on the current page" = "현재 페이지에 배치됨" "Pages: {pages}" = "페이지: {pages}" "Paint bucket" = "페인트 통" @@ -937,6 +952,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "플러그인은 FigCraft 샌드박스에서 파일, 네트워크, 시계 없이 실행됨. 신뢰할 수 있는 플러그인만 설치하세요." "Plus darker" = "더 어둡게" "Plus lighter" = "더 밝게" +"Plus one" = "플러스 원" "Point" = "점" "Points" = "점" "Polygon" = "다각형" @@ -983,6 +999,7 @@ status = "draft — needs native review" "Push" = "밀어내기" "Push changes to main component" = "변경 사항을 메인 컴포넌트에 푸시" "Put text on path" = "텍스트를 패스에 배치" +"Question" = "질문" "R" = "R" "Radial" = "방사형" "Radius" = "반경" @@ -1027,6 +1044,7 @@ status = "draft — needs native review" "Removed" = "제거됨" "Removed from the server" = "서버에서 제거됨" "Removed since it was marked" = "표시된 뒤 제거됨" +"Removed since this version" = "이 버전 이후 삭제됨" "Rename" = "이름 바꾸기" "Rename duplicated layers" = "복제한 레이어 이름 바꾸기" "Rename folder" = "폴더 이름 바꾸기" @@ -1082,6 +1100,7 @@ status = "draft — needs native review" "Rotation jitter" = "회전 지터" "Round" = "둥글게" "Rounded" = "둥근" +"Rounded rectangle" = "둥근 사각형" "Row" = "행" "Row gap" = "행 간격" "Row sizes" = "행 크기" @@ -1151,6 +1170,7 @@ status = "draft — needs native review" "Shape" = "도형" "Shape builder" = "도형 빌더" "Shape tools" = "도형 도구" +"Shapes" = "도형" "Share" = "공유" "Share this file" = "이 파일 공유" "Share: live session, export" = "공유: 라이브 세션, 내보내기" @@ -1208,10 +1228,13 @@ status = "draft — needs native review" "Space+drag" = "Space+드래그" "Spacing" = "간격" "Spam, stolen work, abuse…" = "스팸, 도용, 악용…" +"Speech bubble" = "말풍선" "Splay" = "펼침" "Spotlight me" = "나를 스포트라이트" "Spread" = "확산" "Square" = "정사각형" +"Stamp" = "스탬프" +"Stamps" = "스탬프" "Standard" = "표준" "Standard ligatures" = "표준 합자" "Star" = "별" @@ -1226,12 +1249,16 @@ status = "draft — needs native review" "Starting to share…" = "공유를 시작하는 중…" "Status" = "상태" "Sticky" = "고정" +"Sticky note" = "스티커 메모" +"Sticky note colors" = "스티커 메모 색상" +"Sticky notes, shapes and connectors" = "스티커 메모, 도형, 커넥터" "Stop" = "중지" "Stop following {name}" = "{name} 따라가기 중지" "Stop session" = "세션 중지" "Stop sharing" = "공유 중지" "Stop spotlighting" = "스포트라이트 중지" "Stop {n} {hex}" = "정지점 {n} {hex}" +"Straight" = "직선" "Stretch" = "늘이기" "Strikethrough" = "취소선" "String" = "문자열" @@ -1275,6 +1302,7 @@ status = "draft — needs native review" "Text" = "텍스트" "Text align" = "텍스트 정렬" "Text content" = "텍스트 내용" +"Text size" = "텍스트 크기" "Text style" = "텍스트 스타일" "Text styles" = "텍스트 스타일" "Texts" = "텍스트" @@ -1305,6 +1333,9 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "이 서버는 누구에게나 열려 있음: 키가 필요 없음." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "이 서버는 키가 필요함: 주소에 ?key=…를 추가하세요(서버 컴퓨터에서 `figcraft-server key`로 확인)." "This server takes files only with its key: see Advanced." = "이 서버는 키가 있어야만 파일을 받음: 고급을 참고하세요." +"Thumbs up" = "좋아요" +"This version can't be read." = "이 버전을 읽을 수 없습니다." +"This version doesn't have it: all of it is new." = "이 버전에는 없습니다. 모두 새 항목입니다." "Tidy up" = "정돈" "Tile" = "타일" "Tile type" = "타일 유형" @@ -1321,6 +1352,7 @@ status = "draft — needs native review" "Top left radius" = "왼쪽 위 반경" "Top right radius" = "오른쪽 위 반경" "Transform" = "변형" +"Triangle" = "삼각형" "Triangle arrow" = "삼각 화살표" "Trigger" = "트리거" "True" = "참" @@ -1343,7 +1375,7 @@ status = "draft — needs native review" "Unread" = "읽지 않음" "Unresolve" = "미해결로 되돌리기" "Unsaved changes" = "저장되지 않은 변경 사항" -"Unsaved changes in {names}. Close the window and lose them?" = "{names}에 저장되지 않은 변경 사항이 있음. 창을 닫고 변경 사항을 버릴까요?" +"Unsaved changes in {names}. Save them before closing?" = "{names}에 저장되지 않은 변경 사항이 있음. 닫기 전에 저장할까요?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "저장되지 않은 변경 사항: 먼저 저장({save})하거나 {alt}-클릭으로 탭을 닫아 버리세요" "Unsaved changes: save first, or close again with {key} to discard" = "저장되지 않은 변경 사항: 먼저 저장하거나 {key}로 다시 닫아 버리세요" "Up" = "위" @@ -1406,10 +1438,12 @@ status = "draft — needs native review" "WebSocket URL copied" = "WebSocket URL 복사됨" "Wedge" = "쐐기" "Weight" = "굵기" +"What changed since a saved version" = "저장된 버전 이후 변경된 내용" "What changed since it was marked ready for dev" = "개발 준비 완료로 표시된 뒤 변경된 내용" "What is wrong with this file? The server's operator gets your report." = "이 파일에 어떤 문제가 있나요? 신고는 서버 운영자에게 전달됨." "What it's for" = "용도" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "무엇에서 바뀌었는지 알 수 없음: FigCraft가 개발 준비 완료 프레임의 사본을 보관하기 전에 변경됨. 변경을 해결(또는 다시 준비 완료로 표시)하면 이후부터 비교함." +"When marked ready" = "준비 완료로 표시한 시점" "While hovering" = "마우스를 올린 동안" "While pressing" = "누르는 동안" "White" = "흰색" @@ -1668,6 +1702,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "픽셀에 맞춰 반올림" "arrange.spacing" = "간격" "arrange.tidy" = "정돈" +"board.connector" = "커넥터" +"board.ink" = "마커 획" +"board.label" = "커넥터 레이블" +"board.nextSticky" = "다음 스티커 메모" +"board.setColor" = "보드 색상" +"board.setStyle" = "보드 스타일" +"board.shape" = "텍스트가 있는 도형" +"board.stamp" = "스탬프" +"board.sticky" = "스티커 메모" "bool.exclude" = "선택 항목 제외" "bool.flatten" = "불리언 평면화" "bool.intersect" = "선택 항목 교차" @@ -1792,6 +1835,7 @@ status = "draft — needs native review" "file.import" = "가져오기" "file.moveTab" = "탭 이동" "file.new" = "새 디자인 파일" +"file.newBoard" = "새 보드" "file.open" = "열기" "file.openRecent" = "최근 파일 열기" "file.placeImage" = "이미지 배치" @@ -2055,10 +2099,14 @@ status = "draft — needs native review" "text.weightUp" = "글꼴 굵기 늘리기" "tool.arrow" = "화살표" "tool.comment" = "댓글" +"tool.connector" = "커넥터" "tool.ellipse" = "타원" +"tool.eraser" = "지우개" "tool.frame" = "프레임" "tool.hand" = "손" +"tool.highlighter" = "형광펜" "tool.line" = "선" +"tool.marker" = "마커" "tool.move" = "이동" "tool.pen" = "펜" "tool.pencil" = "연필" @@ -2070,9 +2118,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "배율 기준점" "tool.section" = "섹션" "tool.set" = "도구 선택" +"tool.shape" = "도형" "tool.shapeBuilder" = "도형 빌더" "tool.slice" = "슬라이스" +"tool.stamp" = "스탬프" "tool.star" = "별" +"tool.sticky" = "스티커 메모" "tool.text" = "텍스트" "transform.move" = "이동" "transform.moveTo" = "위치로 이동" @@ -2134,11 +2185,13 @@ status = "draft — needs native review" "vector.edit.tool" = "벡터 편집 도구" "vector.edit.transform" = "점 변형" "vector.edit.widthPoint" = "너비 점" +"vector.fillRule" = "칠 규칙 설정" "vector.flatten" = "평면화" "vector.join" = "선택 항목 연결" "vector.offset" = "패스 오프셋" "vector.outlineStroke" = "선 윤곽선 만들기" "vector.reverse" = "패스 방향 반전" +"vector.reverseLoop" = "루프 반전" "vector.simplify" = "벡터 단순화" "vector.smoothJoin" = "선택 항목 부드럽게 연결" "version.autosave" = "자동 저장 버전" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 54851043..84f13545 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -127,6 +127,7 @@ status = "draft — needs native review" "Archived" = "Gearchiveerd" "Arrange" = "Schikken" "Arrow" = "Pijl" +"Arrow at the end" = "Pijl aan het eind" "As typed" = "Zoals getypt" "Asking the server…" = "Bezig met vragen aan de server…" "Assets" = "Assets" @@ -221,8 +222,10 @@ status = "draft — needs native review" "Changes on main" = "Wijzigingen op main" "Changes on the branch" = "Wijzigingen op de branch" "Changes unknown" = "Onbekende wijzigingen" +"Check" = "Vinkje" "Checking the fonts installed on this computer…" = "De lettertypen op deze computer worden gecontroleerd…" "Checking the server…" = "Server controleren…" +"Chevron" = "Punthaak" "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Kies hierboven React, Leptos, SwiftUI of Compose om dit component aan zijn code te koppelen." "Choose another profile to change it." = "Kies een ander profiel om dit te wijzigen." "Choose collection" = "Kies een verzameling" @@ -245,7 +248,6 @@ status = "draft — needs native review" "Close tab" = "Tabblad sluiten" "Close version history" = "Versiegeschiedenis sluiten" "Close when clicking outside" = "Sluiten bij klikken erbuiten" -"Close without saving" = "Sluiten zonder opslaan" "Code" = "Code" "Code or invite link" = "Code of uitnodigingslink" "Code syntax" = "Code-syntaxis" @@ -290,6 +292,7 @@ status = "draft — needs native review" "Connected" = "Verbonden" "Connected to code: {from}" = "Verbonden met code: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Verbinding verbroken, opnieuw verbinden (poging {attempt})…" +"Connector" = "Verbinding" "Constrain proportions" = "Verhoudingen vergrendelen" "Constraints" = "Constraints" "Content" = "Inhoud" @@ -380,6 +383,7 @@ status = "draft — needs native review" "Description" = "Beschrijving" "Description (optional)" = "Beschrijving (optioneel)" "Design" = "Design" +"Design panel" = "Ontwerppaneel" "Design system starter" = "Designsysteem-starter" "Desktop" = "Desktop" "Destination" = "Bestemming" @@ -407,6 +411,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Horizontale afstand verdelen" "Distribute vertical spacing" = "Verticale afstand verdelen" "Divider" = "Scheidslijn" +"Don't Save" = "Niet opslaan" "Don't truncate" = "Niet afkappen" "Done" = "Klaar" "Done editing" = "Klaar met bewerken" @@ -442,6 +447,7 @@ status = "draft — needs native review" "Effect settings" = "Effectinstellingen" "Effect styles" = "Effectstijlen" "Effects" = "Effecten" +"Elbow" = "Haaks" "Ellipse" = "Ellips" "Ellipses" = "Ellipsen" "Else" = "Anders" @@ -578,6 +584,7 @@ status = "draft — needs native review" "Hanging list" = "Lijst met hangende inspringing" "Hanging punctuation" = "Uithangende leestekens" "Hard light" = "Hard light" +"Heart" = "Hart" "Height" = "Hoogte" "Help" = "Help" "Help and resources" = "Help en bronnen" @@ -590,6 +597,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Deze tips verbergen (Aan de slag in het menu Help toont ze opnieuw)" "Hide when publishing" = "Verbergen bij publiceren" "Highlight layers on hover" = "Lagen uitlichten bij aanwijzen" +"Highlighter" = "Markeerstift" "Highlights" = "Hooglichten" "Hold Alt" = "Houd Alt ingedrukt" "Hold Alt+Z" = "Houd Alt+Z ingedrukt" @@ -756,6 +764,7 @@ status = "draft — needs native review" "Mark as resolved" = "Markeren als opgelost" "Mark as unread" = "Markeren als ongelezen" "Mark as unresolved" = "Markeren als onopgelost" +"Marker" = "Stift" "Mask" = "Masker" "Mass · Stiffness · Damping" = "Massa · Stijfheid · Demping" "Match (empty: the whole name)" = "Overeenkomst (leeg: de hele naam)" @@ -815,6 +824,7 @@ status = "draft — needs native review" "Named instance" = "Benoemde instance" "Navigate to" = "Ga naar" "Nested instance" = "Geneste instance" +"New board" = "Nieuw whiteboard" "New design file" = "Nieuw ontwerpbestand" "Newest first" = "Nieuwste eerst" "Next frame" = "Volgend frame" @@ -918,6 +928,7 @@ status = "draft — needs native review" "Open branch" = "Branch openen" "Open file" = "Bestand openen" "Open files" = "Geopende bestanden" +"Open presentations" = "Geopende presentaties" "Open in focus view" = "Openen in focusweergave" "Open in new window" = "Openen in een nieuw venster" "Open invite links in FigCraft" = "Uitnodigingslinks openen in FigCraft" @@ -987,6 +998,7 @@ status = "draft — needs native review" "Pick the Frame tool, then drag on the canvas or choose a device size" = "Kies de Frame-tool en sleep daarna op het canvas of kies een apparaatgrootte" "Pin a property" = "Eigenschap vastspelden" "Pinch" = "Pinch" +"Pink" = "Roze" "Pixel grid" = "Pixelraster" "Pixel preview 1x" = "Pixelvoorvertoning 1x" "Pixel preview 2x" = "Pixelvoorvertoning 2x" @@ -1001,6 +1013,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins draaien in de sandbox van FigCraft, zonder bestanden, netwerk of klok. Installeer alleen plugins die je vertrouwt." "Plus darker" = "Plus darker" "Plus lighter" = "Plus lighter" +"Plus one" = "Plus één" "Point" = "Punt" "Points" = "Punten" "Polygon" = "Veelhoek" @@ -1048,6 +1061,7 @@ status = "draft — needs native review" "Push" = "Pushen" "Push changes to main component" = "Wijzigingen naar hoofdcomponent doorvoeren" "Put text on path" = "Tekst op pad plaatsen" +"Question" = "Vraag" "R" = "R" "Radial" = "Radiaal" "Radius" = "Radius" @@ -1148,6 +1162,7 @@ status = "draft — needs native review" "Rotation jitter" = "Verspreide rotatie" "Round" = "Rond" "Rounded" = "Rounded" +"Rounded rectangle" = "Afgeronde rechthoek" "Row" = "Rij" "Row gap" = "Rijafstand" "Row sizes" = "Rijgroottes" @@ -1223,6 +1238,7 @@ status = "draft — needs native review" "Shape" = "Vorm" "Shape builder" = "Shape builder" "Shape tools" = "Vormgereedschap" +"Shapes" = "Vormen" "Share" = "Delen" "Share this file" = "Dit bestand delen" "Share with a code" = "Delen met een code" @@ -1289,10 +1305,13 @@ status = "draft — needs native review" "Space+drag" = "Space+drag" "Spacing" = "Afstand" "Spam, stolen work, abuse…" = "Spam, gestolen werk, misbruik…" +"Speech bubble" = "Tekstballon" "Splay" = "Splay" "Spotlight me" = "Spotlight mij" "Spread" = "Spread" "Square" = "Vierkant" +"Stamp" = "Stempel" +"Stamps" = "Stempels" "Standard" = "Standaard" "Standard ligatures" = "Standaardligaturen" "Star" = "Ster" @@ -1309,12 +1328,16 @@ status = "draft — needs native review" "Starting to share…" = "Bezig met delen…" "Status" = "Status" "Sticky" = "Sticky" +"Sticky note" = "Plakbriefje" +"Sticky note colors" = "Kleuren van plakbriefjes" +"Sticky notes, shapes and connectors" = "Plakbriefjes, vormen en verbindingen" "Stop" = "Stoppen" "Stop following {name}" = "Stop met {name} te volgen" "Stop session" = "Sessie stoppen" "Stop sharing" = "Stoppen met delen" "Stop spotlighting" = "Stop met uitlichten" "Stop {n} {hex}" = "Stop {n} {hex}" +"Straight" = "Recht" "Stretch" = "Uitrekken" "Strikethrough" = "Doorstrepen" "String" = "Tekenreeks" @@ -1359,6 +1382,7 @@ status = "draft — needs native review" "Text" = "Tekst" "Text align" = "Tekstuitlijning" "Text content" = "Tekstinhoud" +"Text size" = "Tekstgrootte" "Text style" = "Tekststijl" "Text styles" = "Tekststijlen" "Texts" = "Teksten" @@ -1393,6 +1417,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Deze server is voor iedereen toegankelijk: geen sleutel nodig." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Deze server heeft een sleutel nodig: voeg ?key=… toe aan het adres (`figcraft-server key` toont die op de computer van de server)." "This server takes files only with its key: see Advanced." = "Deze server accepteert bestanden alleen met de bijbehorende sleutel: zie Geavanceerd." +"Thumbs up" = "Duim omhoog" "Tidy up" = "Opruimen" "Tile" = "Tegelen" "Tile type" = "Tegeltype" @@ -1409,6 +1434,7 @@ status = "draft — needs native review" "Top left radius" = "Radius linksboven" "Top right radius" = "Hoekradius rechtsboven" "Transform" = "Transformeren" +"Triangle" = "Driehoek" "Triangle arrow" = "Driehoekspijl" "Trigger" = "Trigger" "True" = "Waar" @@ -1434,7 +1460,7 @@ status = "draft — needs native review" "Unread" = "Ongelezen" "Unresolve" = "Oplossing ongedaan maken" "Unsaved changes" = "Niet-opgeslagen wijzigingen" -"Unsaved changes in {names}. Close the window and lose them?" = "Niet-opgeslagen wijzigingen in {names}. Het venster sluiten en ze verliezen?" +"Unsaved changes in {names}. Save them before closing?" = "Niet-opgeslagen wijzigingen in {names}. Opslaan voor het sluiten?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niet-opgeslagen wijzigingen: sla eerst op ({save}), of sluit het tabblad met {alt}-klik om ze te verwerpen" "Unsaved changes: save first, or close again with {key} to discard" = "Niet-opgeslagen wijzigingen: sla eerst op, of sluit nogmaals met {key} om te verwerpen" "Up" = "Omhoog" @@ -1770,6 +1796,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Afronden op pixels" "arrange.spacing" = "Afstand" "arrange.tidy" = "Opruimen" +"board.connector" = "Verbinding" +"board.ink" = "Stiftlijn" +"board.label" = "Label van verbinding" +"board.nextSticky" = "Volgend plakbriefje" +"board.setColor" = "Whiteboardkleur" +"board.setStyle" = "Whiteboardstijl" +"board.shape" = "Vorm met tekst" +"board.stamp" = "Stempel" +"board.sticky" = "Plakbriefje" "bool.exclude" = "Exclude toepassen op selectie" "bool.flatten" = "Boolean afvlakken" "bool.intersect" = "Intersect toepassen op selectie" @@ -1896,6 +1931,7 @@ status = "draft — needs native review" "file.import" = "Importeren" "file.moveTab" = "Tabblad verplaatsen" "file.new" = "Nieuw ontwerpbestand" +"file.newBoard" = "Nieuw whiteboard" "file.newFromTemplate" = "Nieuw vanuit sjabloon" "file.open" = "Openen" "file.openRecent" = "Onlangs geopende bestanden" @@ -2166,10 +2202,14 @@ status = "draft — needs native review" "text.weightUp" = "Letterdikte vergroten" "tool.arrow" = "Pijl" "tool.comment" = "Reactie" +"tool.connector" = "Verbinding" "tool.ellipse" = "Ellips" +"tool.eraser" = "Gum" "tool.frame" = "Frame" "tool.hand" = "Hand" +"tool.highlighter" = "Markeerstift" "tool.line" = "Lijn" +"tool.marker" = "Stift" "tool.move" = "Verplaatsen" "tool.pen" = "Pen" "tool.pencil" = "Potlood" @@ -2181,9 +2221,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Schaalanker" "tool.section" = "Sectie" "tool.set" = "Gereedschap kiezen" +"tool.shape" = "Vormen" "tool.shapeBuilder" = "Shape builder" "tool.slice" = "Slice" +"tool.stamp" = "Stempel" "tool.star" = "Ster" +"tool.sticky" = "Plakbriefje" "tool.text" = "Tekst" "transform.move" = "Verplaatsen" "transform.moveTo" = "Verplaatsen naar" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 23f7dee8..7f301a6e 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -124,6 +124,7 @@ status = "draft — needs native review" "Archived" = "Zarchiwizowano" "Arrange" = "Ułóż" "Arrow" = "Strzałka" +"Arrow at the end" = "Strzałka na końcu" "As typed" = "Zgodnie z wpisanym" "Asking the server…" = "Odpytywanie serwera…" "Assets" = "Zasoby" @@ -197,12 +198,15 @@ status = "draft — needs native review" "C or ⇧C" = "C lub ⇧C" "CMYK colour" = "Kolor CMYK" "Can view" = "Może przeglądać" +"Check" = "Ptaszek" "Checking the fonts installed on this computer…" = "Sprawdzanie czcionek zainstalowanych na tym komputerze…" "Checking the server…" = "Sprawdzanie serwera…" +"Chevron" = "Szewron" "Clear from this list" = "Usuń z tej listy" "Code or invite link" = "Kod lub link z zaproszeniem" "Color profile: {name}" = "Profil kolorów: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Style kolorów i tekstu, zmienne Jasne i Ciemne, przyciski, pola wprowadzania i karty" +"Connector" = "Łącznik" "Convert to frame" = "Konwertuj na ramkę" "Convert to section" = "Konwertuj na sekcję" "CSS custom properties…" = "Właściwości niestandardowe CSS…" @@ -247,7 +251,6 @@ status = "draft — needs native review" "Close tab" = "Zamknij kartę" "Close version history" = "Zamknij historię wersji" "Close when clicking outside" = "Zamknij przy kliknięciu poza obszarem" -"Close without saving" = "Zamknij bez zapisywania" "Code" = "Kod" "Code syntax" = "Składnia kodu" "Collapse" = "Zwiń" @@ -377,6 +380,7 @@ status = "draft — needs native review" "Description" = "Opis" "Description (optional)" = "Opis (opcjonalnie)" "Design" = "Projektowanie" +"Design panel" = "Panel projektu" "Design system starter" = "Pakiet startowy systemu projektowego (Design System)" "Desktop" = "Pulpit" "Destination" = "Miejsce docelowe" @@ -404,6 +408,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Rozmieść poziomo" "Distribute vertical spacing" = "Rozmieść pionowo" "Divider" = "Rozdzielacz" +"Don't Save" = "Nie zapisuj" "Don't truncate" = "Nie obcinaj tekstu" "Done" = "Gotowe" "Done editing" = "Zakończ edycję" @@ -439,6 +444,7 @@ status = "draft — needs native review" "Effect settings" = "Ustawienia efektów" "Effect styles" = "Style efektów" "Effects" = "Efekty" +"Elbow" = "Łamany" "Ellipse" = "Elipsa" "Ellipses" = "Elipsy" "Else" = "W przeciwnym razie" @@ -575,6 +581,7 @@ status = "draft — needs native review" "Hanging list" = "Lista z wysunięciem" "Hanging punctuation" = "Wiszące znaki interpunkcyjne" "Hard light" = "Mocne światło" +"Heart" = "Serce" "Height" = "Wysokość" "Help" = "Pomoc" "Help and resources" = "Pomoc i zasoby" @@ -587,6 +594,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Ukryj te wskazówki (Pierwsze kroki w menu Pomoc wyświetli je ponownie)" "Hide when publishing" = "Ukryj podczas publikacji" "Highlight layers on hover" = "Wyróżniaj warstwy po najechaniu kursorem" +"Highlighter" = "Zakreślacz" "Highlights" = "Światła" "Hold Alt" = "Przytrzymaj Alt" "Hold Alt+Z" = "Przytrzymaj Alt+Z" @@ -753,6 +761,7 @@ status = "draft — needs native review" "Mark as resolved" = "Oznacz jako rozwiązane" "Mark as unread" = "Oznacz jako nieprzeczytane" "Mark as unresolved" = "Oznacz jako nierozwiązane" +"Marker" = "Marker" "Mask" = "Maska" "Mass · Stiffness · Damping" = "Masa · Sztywność · Tłumienie" "Match (empty: the whole name)" = "Dopasowanie (puste: cała nazwa)" @@ -812,6 +821,7 @@ status = "draft — needs native review" "Named instance" = "Nazwana instancja" "Navigate to" = "Przejdź do" "Nested instance" = "Zagnieżdżona instancja" +"New board" = "Nowa tablica" "New design file" = "Nowy plik projektu" "Newest first" = "Najpierw najnowsze" "Next or previous control in the focused region" = "Następny lub poprzedni element w aktywnym obszarze" @@ -915,6 +925,7 @@ status = "draft — needs native review" "Open branch" = "Otwórz gałąź" "Open file" = "Otwórz plik" "Open files" = "Otwórz pliki" +"Open presentations" = "Otwarte prezentacje" "Open in focus view" = "Otwórz w widoku skupienia" "Open in new window" = "Otwórz w nowym oknie" "Open invite links in FigCraft" = "Otwieraj linki z zaproszeniem w FigCraft" @@ -952,6 +963,7 @@ status = "draft — needs native review" "Page" = "Strona" "Pages" = "Strony" "Pages added to the document" = "Strony dodane do dokumentu" +"Pink" = "Różowy" "Placed on the current page" = "Umieszczono na bieżącej stronie" "Pages: {pages}" = "Strony: {pages}" "Paint bucket" = "Wiadro z farbą" @@ -996,6 +1008,7 @@ status = "draft — needs native review" "Plugins & widgets" = "Wtyczki i widżety" "Plus darker" = "Plus ciemniejszy" "Plus lighter" = "Plus jaśniejszy" +"Plus one" = "Plus jeden" "Point" = "Punkt" "Points" = "Punkty" "Polygon" = "Wielokąt" @@ -1044,6 +1057,7 @@ status = "draft — needs native review" "Push" = "Wypychanie" "Push changes to main component" = "Wypchnij zmiany do głównego komponentu" "Put text on path" = "Umieść tekst na ścieżce" +"Question" = "Pytanie" "R" = "R" "Radial" = "Promienisty" "Radius" = "Promień" @@ -1144,6 +1158,7 @@ status = "draft — needs native review" "Rotation jitter" = "Drżenie obrotu (jitter)" "Round" = "Okrągłe" "Rounded" = "Zaokrąglone" +"Rounded rectangle" = "Zaokrąglony prostokąt" "Row" = "Wiersz" "Row gap" = "Odstęp między wierszami" "Row sizes" = "Rozmiary wierszy" @@ -1219,6 +1234,7 @@ status = "draft — needs native review" "Shape" = "Kształt" "Shape builder" = "Kreator kształtów" "Shape tools" = "Narzędzia kształtów" +"Shapes" = "Kształty" "Share" = "Udostępnij" "Share this file" = "Udostępnij ten plik" "Share with a code" = "Udostępnij za pomocą kodu" @@ -1285,10 +1301,13 @@ status = "draft — needs native review" "Space+drag" = "Spacja+przeciągnięcie" "Spacing" = "Odstępy" "Spam, stolen work, abuse…" = "Spam, plagiat, naruszenie zasad…" +"Speech bubble" = "Dymek" "Splay" = "Rozszerzenie" "Spotlight me" = "Wyróżnij mój widok (Spotlight me)" "Spread" = "Rozszerzenie" "Square" = "Kwadratowe" +"Stamp" = "Pieczątka" +"Stamps" = "Pieczątki" "Standard" = "Standardowy" "Standard ligatures" = "Ligatury standardowe" "Star" = "Gwiazda" @@ -1304,12 +1323,16 @@ status = "draft — needs native review" "Starting to share…" = "Rozpoczynanie udostępniania…" "Status" = "Stan" "Sticky" = "Przyklejony (sticky)" +"Sticky note" = "Karteczka" +"Sticky note colors" = "Kolory karteczek" +"Sticky notes, shapes and connectors" = "Karteczki, kształty i łączniki" "Stop" = "Zatrzymaj" "Stop following {name}" = "Przestań obserwować: {name}" "Stop session" = "Zatrzymaj sesję" "Stop sharing" = "Zatrzymaj udostępnianie" "Stop spotlighting" = "Zatrzymaj wyróżnianie widoku" "Stop {n} {hex}" = "Punkt przejścia {n} {hex}" +"Straight" = "Prosty" "Stretch" = "Rozciągnięcie" "Strikethrough" = "Przekreślenie" "String" = "Tekst (string)" @@ -1354,6 +1377,7 @@ status = "draft — needs native review" "Text" = "Tekst" "Text align" = "Wyrównanie tekstu" "Text content" = "Zawartość tekstu" +"Text size" = "Rozmiar tekstu" "Text style" = "Styl tekstu" "Text styles" = "Style tekstu" "Texts" = "Teksty" @@ -1388,6 +1412,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Ten serwer jest otwarty publicznie: klucz nie jest wymagany." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ten serwer wymaga klucza: dodaj ?key=… do adresu (polecenie `figcraft-server key` wyświetla go na maszynie serwera)." "This server takes files only with its key: see Advanced." = "Ten serwer przyjmuje pliki tylko z kluczem: zobacz Zaawansowane." +"Thumbs up" = "Kciuk w górę" "Tidy up" = "Uporządkuj" "Tile" = "Sąsiadująco" "Tile type" = "Typ kafelka" @@ -1404,6 +1429,7 @@ status = "draft — needs native review" "Top left radius" = "Promień górnego lewego narożnika" "Top right radius" = "Promień górnego prawego narożnika" "Transform" = "Przekształć" +"Triangle" = "Trójkąt" "Triangle arrow" = "Strzałka trójkątna" "Trigger" = "Wyzwalacz" "True" = "Prawda" @@ -1429,7 +1455,7 @@ status = "draft — needs native review" "Unread" = "Nieprzeczytane" "Unresolve" = "Cofnij rozwiązanie" "Unsaved changes" = "Niezapisane zmiany" -"Unsaved changes in {names}. Close the window and lose them?" = "Niezapisane zmiany w: {names}. Zamknąć okno i utracić je?" +"Unsaved changes in {names}. Save them before closing?" = "Niezapisane zmiany w: {names}. Zapisać je przed zamknięciem?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niezapisane zmiany: najpierw zapisz ({save}) lub zamknij kartę z {alt}-kliknięciem, aby odrzucić" "Unsaved changes: save first, or close again with {key} to discard" = "Niezapisane zmiany: najpierw zapisz lub naciśnij ponownie {key}, aby odrzucić" "Up" = "W górę" @@ -1766,6 +1792,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Zaokrąglij do pikseli" "arrange.spacing" = "Odstępy" "arrange.tidy" = "Uporządkuj" +"board.connector" = "Łącznik" +"board.ink" = "Pociągnięcie markera" +"board.label" = "Etykieta łącznika" +"board.nextSticky" = "Następna karteczka" +"board.setColor" = "Kolor na tablicy" +"board.setStyle" = "Styl na tablicy" +"board.shape" = "Kształt z tekstem" +"board.stamp" = "Pieczątka" +"board.sticky" = "Karteczka" "bool.exclude" = "Wyklucz zaznaczenie" "bool.flatten" = "Spłaszcz operację logiczną" "bool.intersect" = "Część wspólna zaznaczenia" @@ -1891,6 +1926,7 @@ status = "draft — needs native review" "file.exportSvg" = "Eksportuj zaznaczenie jako SVG" "file.import" = "Importuj z pliku…" "file.new" = "Nowy plik projektu" +"file.newBoard" = "Nowa tablica" "file.newFromTemplate" = "Nowy z szablonu" "file.open" = "Otwórz…" "file.openRecent" = "Otwórz ostatnie" @@ -2161,10 +2197,14 @@ status = "draft — needs native review" "text.weightUp" = "Zwiększ grubość czcionki" "tool.arrow" = "Strzałka" "tool.comment" = "Komentarz" +"tool.connector" = "Łącznik" "tool.ellipse" = "Elipsa" +"tool.eraser" = "Gumka" "tool.frame" = "Ramka" "tool.hand" = "Rączka" +"tool.highlighter" = "Zakreślacz" "tool.line" = "Linia" +"tool.marker" = "Marker" "tool.move" = "Wskaźnik" "tool.pen" = "Pióro" "tool.pencil" = "Ołówek" @@ -2176,9 +2216,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Punkt zaczepienia skalowania" "tool.section" = "Sekcja" "tool.set" = "Wybierz narzędzie" +"tool.shape" = "Kształty" "tool.shapeBuilder" = "Generator kształtów" "tool.slice" = "Plaster" +"tool.stamp" = "Pieczątka" "tool.star" = "Gwiazda" +"tool.sticky" = "Karteczka" "tool.text" = "Tekst" "transform.move" = "Przenieś" "transform.moveTo" = "Przenieś do" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d8f857e0..22fffb17 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -116,6 +116,7 @@ status = "draft — needs native review" "Archived" = "Arquivada" "Arrange" = "Organizar" "Arrow" = "Seta" +"Arrow at the end" = "Seta no final" "As typed" = "Como digitado" "Asking the server…" = "Consultando o servidor…" "Assets" = "Recursos" @@ -187,7 +188,9 @@ status = "draft — needs native review" "C or ⇧C" = "C ou ⇧C" "CMYK colour" = "Cor CMYK" "Can view" = "Pode visualizar" +"Check" = "Visto" "Checking the server…" = "Verificando o servidor…" +"Chevron" = "Divisa" "Clear from this list" = "Limpar desta lista" "Code or invite link" = "Código ou link de convite" "Color profile: {name}" = "Perfil de cor: {name}" @@ -233,7 +236,6 @@ status = "draft — needs native review" "Close tab" = "Fechar aba" "Close version history" = "Fechar histórico de versões" "Close when clicking outside" = "Fechar ao clicar fora" -"Close without saving" = "Fechar sem salvar" "Code" = "Código" "Code syntax" = "Sintaxe de código" "Collapse" = "Recolher" @@ -260,6 +262,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Empresa" "Compare changes" = "Comparar alterações" +"Compare with" = "Comparar com" "Compatibility notes" = "Notas de compatibilidade" "Completed" = "Concluído" "Component" = "Componente" @@ -275,6 +278,7 @@ status = "draft — needs native review" "Connected" = "Conectado" "Connected to code: {from}" = "Conectado ao código: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Conexão perdida, reconectando (tentativa {attempt})…" +"Connector" = "Conector" "Constrain proportions" = "Manter proporções" "Constraints" = "Restrições" "Content" = "Conteúdo" @@ -359,6 +363,7 @@ status = "draft — needs native review" "Description" = "Descrição" "Description (optional)" = "Descrição (opcional)" "Design" = "Design" +"Design panel" = "Painel de design" "Desktop" = "Desktop" "Destination" = "Destino" "Detach alias" = "Desvincular alias" @@ -369,6 +374,7 @@ status = "draft — needs native review" "Detach variable" = "Desvincular variável" "Details" = "Detalhes" "Dev Mode" = "Modo Dev" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "O modo Dev só inspeciona: mude para Design (Shift+D) para editar" "Dev resources" = "Recursos de desenvolvimento" "Development" = "Desenvolvimento" "Device" = "Dispositivo" @@ -385,6 +391,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Distribuir espaçamento horizontal" "Distribute vertical spacing" = "Distribuir espaçamento vertical" "Divider" = "Divisor" +"Don't Save" = "Não salvar" "Don't truncate" = "Não truncar" "Done" = "Concluído" "Done editing" = "Concluir edição" @@ -415,6 +422,7 @@ status = "draft — needs native review" "Effect settings" = "Configurações do efeito" "Effect styles" = "Estilos de efeito" "Effects" = "Efeitos" +"Elbow" = "Em ângulo" "Ellipse" = "Elipse" "Ellipses" = "Elipses" "Else" = "Senão" @@ -545,6 +553,7 @@ status = "draft — needs native review" "Hanging list" = "Lista com recuo deslocado" "Hanging punctuation" = "Pontuação pendente" "Hard light" = "Luz forte" +"Heart" = "Coração" "Height" = "Altura" "Help" = "Ajuda" "Help and resources" = "Ajuda e recursos" @@ -555,6 +564,7 @@ status = "draft — needs native review" "Hide from publishing" = "Não publicar" "Hide section contents" = "Ocultar conteúdo da seção" "Highlight layers on hover" = "Destacar camadas ao passar o cursor" +"Highlighter" = "Marca-texto" "Highlights" = "Realces" "Hold Alt" = "Segure Alt" "Hold Alt+Z" = "Segure Alt+Z" @@ -710,6 +720,7 @@ status = "draft — needs native review" "Mark as resolved" = "Marcar como resolvido" "Mark as unread" = "Marcar como não lido" "Mark as unresolved" = "Marcar como não resolvido" +"Marker" = "Marcador" "Mask" = "Máscara" "Mass · Stiffness · Damping" = "Massa · Rigidez · Amortecimento" "Match (empty: the whole name)" = "Correspondência (vazio: o nome inteiro)" @@ -766,6 +777,7 @@ status = "draft — needs native review" "Named instance" = "Instância nomeada" "Navigate to" = "Navegar para" "Nested instance" = "Instância aninhada" +"New board" = "Novo quadro" "New design file" = "Novo arquivo de design" "Next frame" = "Próximo quadro" "Next or previous control in the focused region" = "Controle seguinte ou anterior na região em foco" @@ -828,6 +840,7 @@ status = "draft — needs native review" "Not read yet" = "Ainda não lido" "Not shared" = "Não compartilhado" "Note {n}" = "Nota {n}" +"Nothing changed since this version." = "Nada mudou desde esta versão." "Nothing changed since this was marked ready for dev." = "Nada mudou desde que isto foi marcado como pronto para dev." "Nothing here yet. Set a frame's status to list it." = "Ainda não há nada aqui. Defina o status de um quadro para listá-lo." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Nada online ainda. Use Compartilhar em um arquivo para colocá-lo online e ele aparecerá aqui." @@ -861,6 +874,7 @@ status = "draft — needs native review" "Open branch" = "Abrir branch" "Open file" = "Abrir arquivo" "Open files" = "Arquivos abertos" +"Open presentations" = "Apresentações abertas" "Open in focus view" = "Abrir na visualização em foco" "Open in new window" = "Abrir em nova janela" "Open invite links in FigCraft" = "Abrir links de convite no FigCraft" @@ -895,6 +909,7 @@ status = "draft — needs native review" "Page deleted" = "Página excluída" "Pages" = "Páginas" "Pages added to the document" = "Páginas adicionadas ao documento" +"Pink" = "Rosa" "Placed on the current page" = "Colocado na página atual" "Pages: {pages}" = "Páginas: {pages}" "Paint bucket" = "Balde de tinta" @@ -936,6 +951,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Os plugins são executados na sandbox do FigCraft, sem arquivos, rede nem relógio. Instale apenas plugins em que você confia." "Plus darker" = "Mais escuro" "Plus lighter" = "Mais claro" +"Plus one" = "Mais um" "Point" = "Ponto" "Points" = "Pontos" "Polygon" = "Polígono" @@ -982,6 +998,7 @@ status = "draft — needs native review" "Push" = "Empurrar" "Push changes to main component" = "Enviar alterações ao componente principal" "Put text on path" = "Colocar texto no caminho" +"Question" = "Pergunta" "R" = "R" "Radial" = "Radial" "Radius" = "Raio" @@ -1026,6 +1043,7 @@ status = "draft — needs native review" "Removed" = "Removido" "Removed from the server" = "Removido do servidor" "Removed since it was marked" = "Removido desde a marcação" +"Removed since this version" = "Removido desde esta versão" "Rename" = "Renomear" "Rename duplicated layers" = "Renomear camadas duplicadas" "Rename folder" = "Renomear pasta" @@ -1081,6 +1099,7 @@ status = "draft — needs native review" "Rotation jitter" = "Variação de rotação" "Round" = "Arredondado" "Rounded" = "Arredondado" +"Rounded rectangle" = "Retângulo arredondado" "Row" = "Linha" "Row gap" = "Espaço entre linhas" "Row sizes" = "Tamanhos de linha" @@ -1150,6 +1169,7 @@ status = "draft — needs native review" "Shape" = "Forma" "Shape builder" = "Construtor de formas" "Shape tools" = "Ferramentas de forma" +"Shapes" = "Formas" "Share" = "Compartilhar" "Share this file" = "Compartilhar este arquivo" "Share: live session, export" = "Compartilhar: sessão ao vivo, exportar" @@ -1207,10 +1227,13 @@ status = "draft — needs native review" "Space+drag" = "Espaço+arrastar" "Spacing" = "Espaçamento" "Spam, stolen work, abuse…" = "Spam, trabalho roubado, abuso…" +"Speech bubble" = "Balão de fala" "Splay" = "Abertura" "Spotlight me" = "Colocar o foco em mim" "Spread" = "Expansão" "Square" = "Quadrado" +"Stamp" = "Carimbo" +"Stamps" = "Carimbos" "Standard" = "Padrão" "Standard ligatures" = "Ligaduras padrão" "Star" = "Estrela" @@ -1225,12 +1248,16 @@ status = "draft — needs native review" "Starting to share…" = "Começando a compartilhar…" "Status" = "Status" "Sticky" = "Fixado" +"Sticky note" = "Nota adesiva" +"Sticky note colors" = "Cores das notas adesivas" +"Sticky notes, shapes and connectors" = "Notas adesivas, formas e conectores" "Stop" = "Parar" "Stop following {name}" = "Parar de seguir {name}" "Stop session" = "Encerrar sessão" "Stop sharing" = "Parar de compartilhar" "Stop spotlighting" = "Remover o foco" "Stop {n} {hex}" = "Parada {n} {hex}" +"Straight" = "Reto" "Stretch" = "Esticar" "Strikethrough" = "Tachado" "String" = "String" @@ -1274,6 +1301,7 @@ status = "draft — needs native review" "Text" = "Texto" "Text align" = "Alinhamento do texto" "Text content" = "Conteúdo do texto" +"Text size" = "Tamanho do texto" "Text style" = "Estilo de texto" "Text styles" = "Estilos de texto" "Texts" = "Textos" @@ -1304,6 +1332,9 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Este servidor é aberto ao público: não precisa de chave." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor precisa da própria chave: adicione ?key=… ao endereço (`figcraft-server key` a mostra na máquina do servidor)." "This server takes files only with its key: see Advanced." = "Este servidor só aceita arquivos com a própria chave: veja Avançado." +"Thumbs up" = "Joinha" +"This version can't be read." = "Não é possível ler esta versão." +"This version doesn't have it: all of it is new." = "Esta versão não o tem: tudo é novo." "Tidy up" = "Organizar" "Tile" = "Mosaico" "Tile type" = "Tipo de mosaico" @@ -1320,6 +1351,7 @@ status = "draft — needs native review" "Top left radius" = "Raio superior esquerdo" "Top right radius" = "Raio superior direito" "Transform" = "Transformar" +"Triangle" = "Triângulo" "Triangle arrow" = "Seta triangular" "Trigger" = "Gatilho" "True" = "Verdadeiro" @@ -1342,7 +1374,7 @@ status = "draft — needs native review" "Unread" = "Não lidos" "Unresolve" = "Reabrir" "Unsaved changes" = "Alterações não salvas" -"Unsaved changes in {names}. Close the window and lose them?" = "Há alterações não salvas em {names}. Fechar a janela e perdê-las?" +"Unsaved changes in {names}. Save them before closing?" = "Há alterações não salvas em {names}. Salvá-las antes de fechar?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Alterações não salvas: salve primeiro ({save}) ou feche a aba com {alt}+clique para descartá-las" "Unsaved changes: save first, or close again with {key} to discard" = "Alterações não salvas: salve primeiro ou feche de novo com {key} para descartá-las" "Up" = "Para cima" @@ -1405,10 +1437,12 @@ status = "draft — needs native review" "WebSocket URL copied" = "URL do WebSocket copiada" "Wedge" = "Cunha" "Weight" = "Espessura" +"What changed since a saved version" = "O que mudou desde uma versão salva" "What changed since it was marked ready for dev" = "O que mudou desde que foi marcado como pronto para dev" "What is wrong with this file? The server's operator gets your report." = "O que há de errado com este arquivo? O operador do servidor recebe a sua denúncia." "What it's for" = "Para que serve" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Não se sabe de onde esta alteração partiu: ela ocorreu antes de o FigCraft guardar cópias dos quadros prontos para dev. Resolva a alteração (ou marque-o como pronto de novo) para comparar daqui em diante." +"When marked ready" = "Ao marcar como pronto" "While hovering" = "Enquanto passa o cursor" "While pressing" = "Enquanto pressiona" "White" = "Branco" @@ -1781,6 +1815,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Arredondar para pixels" "arrange.spacing" = "Espaçamento" "arrange.tidy" = "Organizar" +"board.connector" = "Conector" +"board.ink" = "Traço de marcador" +"board.label" = "Rótulo do conector" +"board.nextSticky" = "Próxima nota adesiva" +"board.setColor" = "Cor no quadro" +"board.setStyle" = "Estilo no quadro" +"board.shape" = "Forma com texto" +"board.stamp" = "Carimbo" +"board.sticky" = "Nota adesiva" "bool.exclude" = "Excluir sobreposição da seleção" "bool.flatten" = "Nivelar booleano" "bool.intersect" = "Interseção da seleção" @@ -1905,6 +1948,7 @@ status = "draft — needs native review" "file.import" = "Importar" "file.moveTab" = "Mover aba" "file.new" = "Novo arquivo de design" +"file.newBoard" = "Novo quadro" "file.open" = "Abrir" "file.openRecent" = "Abrir recente" "file.placeImage" = "Inserir imagem" @@ -2168,10 +2212,14 @@ status = "draft — needs native review" "text.weightUp" = "Aumentar peso da fonte" "tool.arrow" = "Seta" "tool.comment" = "Comentário" +"tool.connector" = "Conector" "tool.ellipse" = "Elipse" +"tool.eraser" = "Borracha" "tool.frame" = "Quadro" "tool.hand" = "Mão" +"tool.highlighter" = "Marca-texto" "tool.line" = "Linha" +"tool.marker" = "Marcador" "tool.move" = "Mover" "tool.pen" = "Caneta" "tool.pencil" = "Lápis" @@ -2183,9 +2231,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Âncora de escala" "tool.section" = "Seção" "tool.set" = "Escolher ferramenta" +"tool.shape" = "Formas" "tool.shapeBuilder" = "Construtor de formas" "tool.slice" = "Fatia" +"tool.stamp" = "Carimbo" "tool.star" = "Estrela" +"tool.sticky" = "Nota adesiva" "tool.text" = "Texto" "transform.move" = "Mover" "transform.moveTo" = "Mover para" @@ -2247,11 +2298,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Ferramenta de edição vetorial" "vector.edit.transform" = "Transformar pontos" "vector.edit.widthPoint" = "Ponto de largura" +"vector.fillRule" = "Definir regra de preenchimento" "vector.flatten" = "Nivelar" "vector.join" = "Unir seleção" "vector.offset" = "Deslocar caminho" "vector.outlineStroke" = "Contornar traço" "vector.reverse" = "Inverter direção do caminho" +"vector.reverseLoop" = "Inverter laço" "vector.simplify" = "Simplificar vetor" "vector.smoothJoin" = "Unir seleção com suavização" "version.autosave" = "Salvar versão automaticamente" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 6e173a92..baba56fd 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -125,6 +125,7 @@ name = "Русский" "Archived" = "В архиве" "Arrange" = "Упорядочить" "Arrow" = "Стрелка" +"Arrow at the end" = "Стрелка на конце" "As typed" = "Как введено" "Asking the server…" = "Запрос к серверу…" "Assets" = "Ресурсы" @@ -199,8 +200,10 @@ name = "Русский" "C or ⇧C" = "C или ⇧C" "CMYK colour" = "Цвет CMYK" "Can view" = "Может просматривать" +"Check" = "Галочка" "Checking the fonts installed on this computer…" = "Проверка шрифтов, установленных на этом компьютере…" "Checking the server…" = "Проверка сервера…" +"Chevron" = "Шеврон" "Clear from this list" = "Убрать из этого списка" "Code or invite link" = "Код или ссылка приглашения" "Color profile: {name}" = "Цветовой профиль: {name}" @@ -247,7 +250,6 @@ name = "Русский" "Close tab" = "Закрыть вкладку" "Close version history" = "Закрыть историю версий" "Close when clicking outside" = "Закрывать при нажатии снаружи" -"Close without saving" = "Закрыть без сохранения" "Code" = "Код" "Code syntax" = "Синтаксис кода" "Collapse" = "Свернуть" @@ -289,6 +291,7 @@ name = "Русский" "Connected" = "Подключено" "Connected to code: {from}" = "Связано с кодом: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Соединение потеряно, переподключение (попытка {attempt})…" +"Connector" = "Соединитель" "Constrain proportions" = "Сохранять пропорции" "Constraints" = "Ограничения" "Content" = "Содержимое" @@ -379,6 +382,7 @@ name = "Русский" "Description" = "Описание" "Description (optional)" = "Описание (необязательно)" "Design" = "Дизайн" +"Design panel" = "Панель дизайна" "Design system starter" = "Основа дизайн-системы" "Desktop" = "Компьютер" "Destination" = "Назначение" @@ -406,6 +410,7 @@ name = "Русский" "Distribute horizontal spacing" = "Распределить по горизонтали" "Distribute vertical spacing" = "Распределить по вертикали" "Divider" = "Разделитель" +"Don't Save" = "Не сохранять" "Don't truncate" = "Не обрезать" "Done" = "Готово" "Done editing" = "Завершить редактирование" @@ -441,6 +446,7 @@ name = "Русский" "Effect settings" = "Настройки эффекта" "Effect styles" = "Стили эффектов" "Effects" = "Эффекты" +"Elbow" = "Ломаная" "Ellipse" = "Эллипс" "Ellipses" = "Эллипсы" "Else" = "Иначе" @@ -577,6 +583,7 @@ name = "Русский" "Hanging list" = "Выступающий список" "Hanging punctuation" = "Висячая пунктуация" "Hard light" = "Жёсткий свет" +"Heart" = "Сердце" "Height" = "Высота" "Help" = "Справка" "Help and resources" = "Справка и ресурсы" @@ -589,6 +596,7 @@ name = "Русский" "Hide these tips (Getting started in the Help menu shows them again)" = "Скрыть подсказки (их можно вернуть через «Начало работы» в меню справки)" "Hide when publishing" = "Скрывать при публикации" "Highlight layers on hover" = "Подсвечивать слои при наведении" +"Highlighter" = "Текстовыделитель" "Highlights" = "Света" "Hold Alt" = "Удерживайте Alt" "Hold Alt+Z" = "Удерживайте Alt+Z" @@ -758,6 +766,7 @@ name = "Русский" "Mark as resolved" = "Отметить как решённое" "Mark as unread" = "Отметить как непрочитанное" "Mark as unresolved" = "Отметить как нерешённое" +"Marker" = "Маркер" "Mask" = "Маска" "Mass · Stiffness · Damping" = "Масса · Жёсткость · Демпфирование" "Match (empty: the whole name)" = "Совпадение (пусто: всё имя)" @@ -817,6 +826,7 @@ name = "Русский" "Named instance" = "Именованный экземпляр" "Navigate to" = "Перейти к" "Nested instance" = "Вложенный экземпляр" +"New board" = "Новая доска" "New design file" = "Новый файл дизайна" "Newest first" = "Сначала новые" "Next frame" = "Следующий Frame" @@ -920,6 +930,7 @@ name = "Русский" "Open branch" = "Открыть ветку" "Open file" = "Открыть файл" "Open files" = "Открыть файлы" +"Open presentations" = "Открытые презентации" "Open in focus view" = "Открыть в режиме фокуса" "Open in new window" = "Открыть в новом окне" "Open invite links in FigCraft" = "Открывать ссылки приглашения в FigCraft" @@ -958,6 +969,7 @@ name = "Русский" "Page deleted" = "Страница удалена" "Pages" = "Страницы" "Pages added to the document" = "Страницы добавлены в документ" +"Pink" = "Розовый" "Placed on the current page" = "Размещено на текущей странице" "Pages: {pages}" = "Страницы: {pages}" "Paint bucket" = "Заливка области" @@ -1002,6 +1014,7 @@ name = "Русский" "Plugins" = "Плагины" "Plus darker" = "Линейное затемнение" "Plus lighter" = "Линейное осветление" +"Plus one" = "Плюс один" "Point" = "Точка" "Points" = "Точки" "Polygon" = "Многоугольник" @@ -1050,6 +1063,7 @@ name = "Русский" "Push" = "Вытеснение" "Push changes to main component" = "Перенести изменения в основной компонент" "Put text on path" = "Разместить текст на пути" +"Question" = "Вопрос" "R" = "R" "Radial" = "Радиальный" "Radius" = "Радиус" @@ -1153,6 +1167,7 @@ name = "Русский" "Rotation jitter" = "Колебание угла" "Round" = "Круглое" "Rounded" = "Скруглённый" +"Rounded rectangle" = "Скруглённый прямоугольник" "Row" = "Строка" "Row gap" = "Интервал между строками" "Row sizes" = "Размеры строк" @@ -1229,6 +1244,7 @@ name = "Русский" "Shape" = "Фигура" "Shape builder" = "Конструктор фигур" "Shape tools" = "Инструменты фигур" +"Shapes" = "Фигуры" "Share" = "Поделиться" "Share this file" = "Поделиться файлом" "Share with a code" = "Поделиться по коду" @@ -1295,10 +1311,13 @@ name = "Русский" "Space+drag" = "Пробел+перетаскивание" "Spacing" = "Интервал" "Spam, stolen work, abuse…" = "Спам, украденные работы, оскорбления…" +"Speech bubble" = "Выноска" "Splay" = "Расхождение" "Spotlight me" = "Показывать мой экран всем" "Spread" = "Размах" "Square" = "Квадрат" +"Stamp" = "Штамп" +"Stamps" = "Штампы" "Standard" = "Стандартный" "Standard ligatures" = "Стандартные лигатуры" "Star" = "Звезда" @@ -1315,12 +1334,16 @@ name = "Русский" "Starting to share…" = "Включение общего доступа…" "Status" = "Статус" "Sticky" = "Закреплённый" +"Sticky note" = "Стикер" +"Sticky note colors" = "Цвета стикеров" +"Sticky notes, shapes and connectors" = "Стикеры, фигуры и соединители" "Stop" = "Остановить" "Stop following {name}" = "Перестать следить за {name}" "Stop session" = "Завершить сессию" "Stop sharing" = "Остановить общий доступ" "Stop spotlighting" = "Прекратить показ всем" "Stop {n} {hex}" = "Точка {n} {hex}" +"Straight" = "Прямая" "Stretch" = "Растянуть" "Strikethrough" = "Зачёркивание" "String" = "Строка" @@ -1365,6 +1388,7 @@ name = "Русский" "Text" = "Текст" "Text align" = "Выравнивание текста" "Text content" = "Содержимое текста" +"Text size" = "Размер текста" "Text style" = "Текстовый стиль" "Text styles" = "Текстовые стили" "Texts" = "Тексты" @@ -1401,6 +1425,7 @@ name = "Русский" "This server is open to the public: no key needed." = "Сервер общедоступен: ключ не нужен." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Сервер требует ключ: добавьте ?key=… к адресу (`figcraft-server key` выводит его на компьютере сервера)." "This server takes files only with its key: see Advanced." = "Сервер принимает файлы только с ключом: см. «Дополнительно»." +"Thumbs up" = "Палец вверх" "Tidy up" = "Упорядочить" "Tile" = "Повторение" "Tile type" = "Тип повторения" @@ -1418,6 +1443,7 @@ name = "Русский" "Top padding" = "Верхний внутренний отступ" "Top right radius" = "Радиус верхнего правого угла" "Transform" = "Трансформация" +"Triangle" = "Треугольник" "Triangle arrow" = "Треугольная стрелка" "Trigger" = "Триггер" "True" = "Истина" @@ -1443,7 +1469,7 @@ name = "Русский" "Unread" = "Непрочитанные" "Unresolve" = "Снова открыть" "Unsaved changes" = "Несохранённые изменения" -"Unsaved changes in {names}. Close the window and lose them?" = "Несохранённые изменения в {names}. Закрыть окно и потерять их?" +"Unsaved changes in {names}. Save them before closing?" = "Несохранённые изменения в {names}. Сохранить их перед закрытием?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Есть несохранённые изменения: сохраните ({save}) или закройте вкладку нажатием с {alt}, чтобы отбросить их" "Unsaved changes: save first, or close again with {key} to discard" = "Есть несохранённые изменения: сохраните или закройте ещё раз с {key}, чтобы отбросить их" "Up" = "Вверх" @@ -1783,6 +1809,15 @@ name = "Русский" "arrange.roundToPixels" = "Округлить до пикселей" "arrange.spacing" = "Интервал" "arrange.tidy" = "Упорядочить" +"board.connector" = "Соединитель" +"board.ink" = "Штрих маркера" +"board.label" = "Подпись соединителя" +"board.nextSticky" = "Следующий стикер" +"board.setColor" = "Цвет на доске" +"board.setStyle" = "Стиль на доске" +"board.shape" = "Фигура с текстом" +"board.stamp" = "Штамп" +"board.sticky" = "Стикер" "bool.exclude" = "Исключить выделение" "bool.flatten" = "Свести булеву группу" "bool.intersect" = "Пересечь выделение" @@ -1909,6 +1944,7 @@ name = "Русский" "file.import" = "Импорт" "file.moveTab" = "Переместить вкладку" "file.new" = "Новый файл дизайна" +"file.newBoard" = "Новая доска" "file.newFromTemplate" = "Создать из шаблона" "file.open" = "Открыть" "file.openRecent" = "Открыть недавние" @@ -2183,10 +2219,14 @@ name = "Русский" "text.weightUp" = "Увеличить толщину шрифта" "tool.arrow" = "Стрелка" "tool.comment" = "Комментарий" +"tool.connector" = "Соединитель" "tool.ellipse" = "Эллипс" +"tool.eraser" = "Ластик" "tool.frame" = "Frame" "tool.hand" = "Рука" +"tool.highlighter" = "Текстовыделитель" "tool.line" = "Линия" +"tool.marker" = "Маркер" "tool.move" = "Перемещение" "tool.pen" = "Перо" "tool.pencil" = "Карандаш" @@ -2198,9 +2238,12 @@ name = "Русский" "tool.scaleAnchor" = "Центр масштабирования" "tool.section" = "Секция" "tool.set" = "Выбрать инструмент" +"tool.shape" = "Фигуры" "tool.shapeBuilder" = "Конструктор фигур" "tool.slice" = "Срез" +"tool.stamp" = "Штамп" "tool.star" = "Звезда" +"tool.sticky" = "Стикер" "tool.text" = "Текст" "transform.move" = "Перемещение" "transform.moveTo" = "Переместить в" @@ -2262,11 +2305,13 @@ name = "Русский" "vector.edit.tool" = "Инструмент редактирования вектора" "vector.edit.transform" = "Трансформировать точки" "vector.edit.widthPoint" = "Точка ширины" +"vector.fillRule" = "Задать правило заливки" "vector.flatten" = "Свести" "vector.join" = "Соединить выделение" "vector.offset" = "Сместить путь" "vector.outlineStroke" = "Преобразовать обводку в контур" "vector.reverse" = "Обратить направление пути" +"vector.reverseLoop" = "Развернуть контур" "vector.simplify" = "Упростить вектор" "vector.smoothJoin" = "Плавно соединить выделение" "version.autosave" = "Автосохранить версию" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d86779e0..be25d38e 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -124,6 +124,7 @@ name = "Svenska" "Archived" = "Arkiverad" "Arrange" = "Ordna" "Arrow" = "Pil" +"Arrow at the end" = "Pil i slutet" "As typed" = "Som inskrivet" "Asking the server…" = "Frågar servern…" "Assets" = "Tillgångar" @@ -197,7 +198,9 @@ name = "Svenska" "C or ⇧C" = "C eller ⇧C" "CMYK colour" = "CMYK-färg" "Can view" = "Kan visa" +"Check" = "Bock" "Checking the server…" = "Kontrollerar servern…" +"Chevron" = "Vinkel" "Clear from this list" = "Ta bort från listan" "Code or invite link" = "Kod eller inbjudningslänk" "Color profile: {name}" = "Färgprofil: {name}" @@ -244,7 +247,6 @@ name = "Svenska" "Close tab" = "Stäng flik" "Close version history" = "Stäng versionshistorik" "Close when clicking outside" = "Stäng vid klick utanför" -"Close without saving" = "Stäng utan att spara" "Code" = "Kod" "Code syntax" = "Kodsyntax" "Collapse" = "Komprimera" @@ -286,6 +288,7 @@ name = "Svenska" "Connected" = "Kopplad" "Connected to code: {from}" = "Kopplad till kod: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Anslutningen bröts, återansluter (försök {attempt})…" +"Connector" = "Koppling" "Constrain proportions" = "Behåll proportioner" "Constraints" = "Begränsningar" "Content" = "Innehåll" @@ -374,6 +377,7 @@ name = "Svenska" "Description" = "Beskrivning" "Description (optional)" = "Beskrivning (valfritt)" "Design" = "Design" +"Design panel" = "Designpanel" "Design system starter" = "Startmall för designsystem" "Desktop" = "Skrivbord" "Destination" = "Mål" @@ -401,6 +405,7 @@ name = "Svenska" "Distribute horizontal spacing" = "Fördela horisontellt avstånd" "Distribute vertical spacing" = "Fördela vertikalt avstånd" "Divider" = "Avdelare" +"Don't Save" = "Spara inte" "Don't truncate" = "Korta inte av" "Done" = "Klart" "Done editing" = "Klar med redigeringen" @@ -436,6 +441,7 @@ name = "Svenska" "Effect settings" = "Effektinställningar" "Effect styles" = "Effektstilar" "Effects" = "Effekter" +"Elbow" = "Vinklad" "Ellipse" = "Ellips" "Ellipses" = "Ellipser" "Else" = "Annars" @@ -572,6 +578,7 @@ name = "Svenska" "Hanging list" = "Hängande lista" "Hanging punctuation" = "Hängande skiljetecken" "Hard light" = "Hårt ljus" +"Heart" = "Hjärta" "Height" = "Höjd" "Help" = "Hjälp" "Help and resources" = "Hjälp och resurser" @@ -584,6 +591,7 @@ name = "Svenska" "Hide these tips (Getting started in the Help menu shows them again)" = "Dölj de här tipsen (Kom igång i Hjälp-menyn visar dem igen)" "Hide when publishing" = "Dölj vid publicering" "Highlight layers on hover" = "Framhäv lager vid hovring" +"Highlighter" = "Överstrykningspenna" "Highlights" = "Högdagrar" "Hold Alt" = "Håll ned Alt" "Hold Alt+Z" = "Håll ned Alt+Z" @@ -750,6 +758,7 @@ name = "Svenska" "Mark as resolved" = "Märk som löst" "Mark as unread" = "Märk som oläst" "Mark as unresolved" = "Märk som olöst" +"Marker" = "Märkpenna" "Mask" = "Mask" "Mass · Stiffness · Damping" = "Massa · Styvhet · Dämpning" "Match (empty: the whole name)" = "Matcha (tomt: hela namnet)" @@ -808,6 +817,7 @@ name = "Svenska" "Named instance" = "Namngiven instans" "Navigate to" = "Navigera till" "Nested instance" = "Kapslad instans" +"New board" = "Ny whiteboard" "New design file" = "Ny designfil" "Newest first" = "Nyaste först" "Next frame" = "Nästa ram" @@ -909,6 +919,7 @@ name = "Svenska" "Open branch" = "Öppna gren" "Open file" = "Öppna fil" "Open files" = "Öppna filer" +"Open presentations" = "Öppna presentationer" "Open in focus view" = "Öppna i fokusvy" "Open in new window" = "Öppna i nytt fönster" "Open invite links in FigCraft" = "Öppna inbjudningslänkar i FigCraft" @@ -946,6 +957,7 @@ name = "Svenska" "Page deleted" = "Sidan har raderats" "Pages" = "Sidor" "Pages added to the document" = "Sidor tillagda i dokumentet" +"Pink" = "Rosa" "Placed on the current page" = "Placerat på den aktuella sidan" "Pages: {pages}" = "Sidor: {pages}" "Paint bucket" = "Färghink" @@ -988,6 +1000,7 @@ name = "Svenska" "Plugins" = "Plugin-program" "Plus darker" = "Plus mörkare" "Plus lighter" = "Plus ljusare" +"Plus one" = "Plus ett" "Point" = "Punkt" "Points" = "Punkter" "Polygon" = "Polygon" @@ -1036,6 +1049,7 @@ name = "Svenska" "Push" = "Skjut" "Push changes to main component" = "Skicka ändringar till huvudkomponenten" "Put text on path" = "Placera text på bana" +"Question" = "Fråga" "R" = "R" "Radial" = "Radiell" "Radius" = "Radie" @@ -1136,6 +1150,7 @@ name = "Svenska" "Rotation jitter" = "Rotationsdarr" "Round" = "Rund" "Rounded" = "Rundad" +"Rounded rectangle" = "Rundad rektangel" "Row" = "Rad" "Row gap" = "Radmellanrum" "Row sizes" = "Radstorlekar" @@ -1209,6 +1224,7 @@ name = "Svenska" "Shape" = "Form" "Shape builder" = "Formbyggare" "Shape tools" = "Formverktyg" +"Shapes" = "Former" "Share" = "Dela" "Share this file" = "Dela den här filen" "Share with a code" = "Dela med en kod" @@ -1275,10 +1291,13 @@ name = "Svenska" "Space+drag" = "Blanksteg+dra" "Spacing" = "Avstånd" "Spam, stolen work, abuse…" = "Skräppost, stulet material, kränkningar…" +"Speech bubble" = "Pratbubbla" "Splay" = "Utbredning" "Spotlight me" = "Rikta strålkastaren mot mig" "Spread" = "Spridning" "Square" = "Kvadrat" +"Stamp" = "Stämpel" +"Stamps" = "Stämplar" "Standard" = "Standard" "Standard ligatures" = "Standardligaturer" "Star" = "Stjärna" @@ -1295,12 +1314,16 @@ name = "Svenska" "Starting to share…" = "Börjar dela…" "Status" = "Status" "Sticky" = "Fast" +"Sticky note" = "Lapp" +"Sticky note colors" = "Lappfärger" +"Sticky notes, shapes and connectors" = "Lappar, former och kopplingar" "Stop" = "Stoppa" "Stop following {name}" = "Sluta följa {name}" "Stop session" = "Avsluta session" "Stop sharing" = "Sluta dela" "Stop spotlighting" = "Sluta använda strålkastaren" "Stop {n} {hex}" = "Stopp {n} {hex}" +"Straight" = "Rak" "Stretch" = "Sträck" "Strikethrough" = "Genomstruken" "String" = "Sträng" @@ -1345,6 +1368,7 @@ name = "Svenska" "Text" = "Text" "Text align" = "Textjustering" "Text content" = "Textinnehåll" +"Text size" = "Textstorlek" "Text style" = "Textstil" "Text styles" = "Textstilar" "Texts" = "Texter" @@ -1378,6 +1402,7 @@ name = "Svenska" "This server is open to the public: no key needed." = "Servern är öppen för alla: ingen nyckel behövs." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Servern kräver sin nyckel: lägg till ?key=… i adressen (`figcraft-server key` skriver ut den på serverns dator)." "This server takes files only with its key: see Advanced." = "Servern tar bara emot filer med sin nyckel: se Avancerat." +"Thumbs up" = "Tummen upp" "Tidy up" = "Snygga till" "Tile" = "Sida vid sida" "Tile type" = "Upprepningstyp" @@ -1394,6 +1419,7 @@ name = "Svenska" "Top left radius" = "Radie uppe till vänster" "Top right radius" = "Radie uppe till höger" "Transform" = "Transformera" +"Triangle" = "Triangel" "Triangle arrow" = "Triangelpil" "Trigger" = "Utlösare" "True" = "Sant" @@ -1418,7 +1444,7 @@ name = "Svenska" "Unread" = "Olästa" "Unresolve" = "Markera som olöst" "Unsaved changes" = "Osparade ändringar" -"Unsaved changes in {names}. Close the window and lose them?" = "Osparade ändringar i {names}. Vill du stänga fönstret och förlora dem?" +"Unsaved changes in {names}. Save them before closing?" = "Osparade ändringar i {names}. Vill du spara dem innan du stänger?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Osparade ändringar: spara först ({save}), eller stäng fliken med {alt}-klick för att slänga dem" "Unsaved changes: save first, or close again with {key} to discard" = "Osparade ändringar: spara först, eller stäng igen med {key} för att slänga dem" "Up" = "Upp" @@ -1752,6 +1778,15 @@ name = "Svenska" "arrange.roundToPixels" = "Avrunda till pixlar" "arrange.spacing" = "Avstånd" "arrange.tidy" = "Snygga till" +"board.connector" = "Koppling" +"board.ink" = "Märkpennsdrag" +"board.label" = "Kopplingsetikett" +"board.nextSticky" = "Nästa lapp" +"board.setColor" = "Whiteboardfärg" +"board.setStyle" = "Whiteboardstil" +"board.shape" = "Form med text" +"board.stamp" = "Stämpel" +"board.sticky" = "Lapp" "bool.exclude" = "Uteslut markering" "bool.flatten" = "Platta till boolesk form" "bool.intersect" = "Skärning av markering" @@ -1878,6 +1913,7 @@ name = "Svenska" "file.import" = "Importera" "file.moveTab" = "Flytta flik" "file.new" = "Nytt" +"file.newBoard" = "Ny whiteboard" "file.newFromTemplate" = "Nytt från mall" "file.open" = "Öppna" "file.openRecent" = "Öppna senaste" @@ -2147,10 +2183,14 @@ name = "Svenska" "text.weightUp" = "Öka teckenvikten" "tool.arrow" = "Pil" "tool.comment" = "Kommentar" +"tool.connector" = "Koppling" "tool.ellipse" = "Ellips" +"tool.eraser" = "Suddgummi" "tool.frame" = "Ram" "tool.hand" = "Hand" +"tool.highlighter" = "Överstrykningspenna" "tool.line" = "Linje" +"tool.marker" = "Märkpenna" "tool.move" = "Flytta" "tool.pen" = "Penna" "tool.pencil" = "Blyertspenna" @@ -2162,9 +2202,12 @@ name = "Svenska" "tool.scaleAnchor" = "Skalningsankare" "tool.section" = "Avsnitt" "tool.set" = "Välj verktyg" +"tool.shape" = "Former" "tool.shapeBuilder" = "Formbyggare" "tool.slice" = "Skiva" +"tool.stamp" = "Stämpel" "tool.star" = "Stjärna" +"tool.sticky" = "Lapp" "tool.text" = "Text" "transform.move" = "Flytta" "transform.moveTo" = "Flytta till" @@ -2226,11 +2269,13 @@ name = "Svenska" "vector.edit.tool" = "Vektorredigeringsverktyg" "vector.edit.transform" = "Transformera punkter" "vector.edit.widthPoint" = "Breddpunkt" +"vector.fillRule" = "Ange fyllnadsregel" "vector.flatten" = "Platta till" "vector.join" = "Sammanfoga markering" "vector.offset" = "Förskjut bana" "vector.outlineStroke" = "Konvertera kontur till form" "vector.reverse" = "Vänd banans riktning" +"vector.reverseLoop" = "Vänd slinga" "vector.simplify" = "Förenkla vektor" "vector.smoothJoin" = "Sammanfoga markering mjukt" "version.autosave" = "Autospara version" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index bd8bb7c9..5d15c9c9 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -124,6 +124,7 @@ name = "Türkçe" "Archived" = "Arşivlendi" "Arrange" = "Konumlandır" "Arrow" = "Ok" +"Arrow at the end" = "Sonda ok" "As typed" = "Yazıldığı gibi" "Asking the server…" = "Sunucuya soruluyor…" "Assets" = "Varlıklar" @@ -197,7 +198,9 @@ name = "Türkçe" "C or ⇧C" = "C veya ⇧C" "CMYK colour" = "CMYK rengi" "Can view" = "Görüntüleyebilir" +"Check" = "Onay" "Checking the server…" = "Sunucu denetleniyor…" +"Chevron" = "Şerit ok" "Clear from this list" = "Bu listeden kaldır" "Code or invite link" = "Kod veya davet bağlantısı" "Color profile: {name}" = "Renk profili: {name}" @@ -244,7 +247,6 @@ name = "Türkçe" "Close tab" = "Sekmeyi kapat" "Close version history" = "Sürüm geçmişini kapat" "Close when clicking outside" = "Dışarı tıklanınca kapat" -"Close without saving" = "Kaydetmeden kapat" "Code" = "Kod" "Code syntax" = "Kod söz dizimi" "Collapse" = "Daralt" @@ -286,6 +288,7 @@ name = "Türkçe" "Connected" = "Bağlandı" "Connected to code: {from}" = "Koda bağlı: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Bağlantı koptu, yeniden bağlanılıyor (deneme {attempt})…" +"Connector" = "Bağlayıcı" "Constrain proportions" = "Oranları koru" "Constraints" = "Kısıtlamalar" "Content" = "İçerik" @@ -372,6 +375,7 @@ name = "Türkçe" "Description" = "Açıklama" "Description (optional)" = "Açıklama (isteğe bağlı)" "Design" = "Tasarım" +"Design panel" = "Tasarım paneli" "Design system starter" = "Tasarım sistemi başlangıç seti" "Desktop" = "Masaüstü" "Destination" = "Hedef" @@ -399,6 +403,7 @@ name = "Türkçe" "Distribute horizontal spacing" = "Yatay aralığı dağıt" "Distribute vertical spacing" = "Dikey aralığı dağıt" "Divider" = "Ayırıcı" +"Don't Save" = "Kaydetme" "Don't truncate" = "Kısaltma yapma" "Done" = "Bitti" "Done editing" = "Düzenlemeyi bitir" @@ -431,6 +436,7 @@ name = "Türkçe" "Effect settings" = "Efekt ayarları" "Effect styles" = "Efekt stilleri" "Effects" = "Efektler" +"Elbow" = "Dirsekli" "Ellipse" = "Elips" "Ellipses" = "Elipsler" "Else" = "Değilse" @@ -566,6 +572,7 @@ name = "Türkçe" "Hanging list" = "Asılı liste" "Hanging punctuation" = "Asılı noktalama" "Hard light" = "Sert ışık" +"Heart" = "Kalp" "Height" = "Yükseklik" "Help" = "Yardım" "Help and resources" = "Yardım ve kaynaklar" @@ -578,6 +585,7 @@ name = "Türkçe" "Hide these tips (Getting started in the Help menu shows them again)" = "Bu ipuçlarını gizle (Yardım menüsündeki Başlarken onları yeniden gösterir)" "Hide when publishing" = "Yayımlarken gizle" "Highlight layers on hover" = "Üzerine gelince katmanları vurgula" +"Highlighter" = "Fosforlu kalem" "Highlights" = "Açık tonlar" "Hold Alt" = "Alt tuşunu basılı tutun" "Hold Alt+Z" = "Alt+Z tuşlarını basılı tutun" @@ -743,6 +751,7 @@ name = "Türkçe" "Mark as resolved" = "Çözüldü olarak işaretle" "Mark as unread" = "Okunmadı olarak işaretle" "Mark as unresolved" = "Çözülmedi olarak işaretle" +"Marker" = "Keçeli kalem" "Mask" = "Maske" "Mass · Stiffness · Damping" = "Kütle · Sertlik · Sönüm" "Match (empty: the whole name)" = "Eşleşme (boş: adın tamamı)" @@ -801,6 +810,7 @@ name = "Türkçe" "Named instance" = "Adlandırılmış örnek" "Navigate to" = "Şuraya git" "Nested instance" = "İç içe örnek" +"New board" = "Yeni pano" "New design file" = "Yeni tasarım dosyası" "Newest first" = "Önce en yeni" "Next frame" = "Sonraki çerçeve" @@ -902,6 +912,7 @@ name = "Türkçe" "Open branch" = "Dalı aç" "Open file" = "Dosya aç" "Open files" = "Dosyaları aç" +"Open presentations" = "Açık sunumlar" "Open in focus view" = "Odak görünümünde aç" "Open in new window" = "Yeni pencerede aç" "Open invite links in FigCraft" = "Davet bağlantılarını FigCraft'ta aç" @@ -937,6 +948,7 @@ name = "Türkçe" "Page deleted" = "Sayfa silindi" "Pages" = "Sayfalar" "Pages added to the document" = "Sayfalar belgeye eklendi" +"Pink" = "Pembe" "Placed on the current page" = "Geçerli sayfaya yerleştirildi" "Pages: {pages}" = "Sayfalar: {pages}" "Paint bucket" = "Boya kovası" @@ -979,6 +991,7 @@ name = "Türkçe" "Plugins" = "Eklentiler" "Plus darker" = "Artı koyu" "Plus lighter" = "Artı açık" +"Plus one" = "Artı bir" "Point" = "Nokta" "Points" = "Noktalar" "Polygon" = "Çokgen" @@ -1027,6 +1040,7 @@ name = "Türkçe" "Push" = "İt" "Push changes to main component" = "Değişiklikleri ana bileşene gönder" "Put text on path" = "Metni yola yerleştir" +"Question" = "Soru" "R" = "R" "Radial" = "Dairesel" "Radius" = "Yarıçap" @@ -1126,6 +1140,7 @@ name = "Türkçe" "Rotation jitter" = "Döndürme titreşimi" "Round" = "Yuvarlak" "Rounded" = "Yuvarlatılmış" +"Rounded rectangle" = "Yuvarlatılmış dikdörtgen" "Row" = "Satır" "Row gap" = "Satır aralığı" "Row sizes" = "Satır boyutları" @@ -1198,6 +1213,7 @@ name = "Türkçe" "Shape" = "Şekil" "Shape builder" = "Şekil oluşturucu" "Shape tools" = "Şekil araçları" +"Shapes" = "Şekiller" "Share" = "Paylaş" "Share this file" = "Bu dosyayı paylaş" "Share with a code" = "Kodla paylaş" @@ -1264,10 +1280,13 @@ name = "Türkçe" "Space+drag" = "Boşluk+sürükle" "Spacing" = "Aralık" "Spam, stolen work, abuse…" = "İstenmeyen içerik, çalıntı iş, kötüye kullanım…" +"Speech bubble" = "Konuşma balonu" "Splay" = "Açılma" "Spotlight me" = "Beni öne çıkar" "Spread" = "Yayılma" "Square" = "Kare" +"Stamp" = "Damga" +"Stamps" = "Damgalar" "Standard" = "Standart" "Standard ligatures" = "Standart bitişik harfler" "Star" = "Yıldız" @@ -1283,12 +1302,16 @@ name = "Türkçe" "Starting to share…" = "Paylaşım başlatılıyor…" "Status" = "Durum" "Sticky" = "Yapışkan" +"Sticky note" = "Yapışkan not" +"Sticky note colors" = "Yapışkan not renkleri" +"Sticky notes, shapes and connectors" = "Yapışkan notlar, şekiller ve bağlayıcılar" "Stop" = "Durdur" "Stop following {name}" = "{name} takibini bırak" "Stop session" = "Oturumu durdur" "Stop sharing" = "Paylaşmayı durdur" "Stop spotlighting" = "Öne çıkarmayı durdur" "Stop {n} {hex}" = "Durak {n} {hex}" +"Straight" = "Düz" "Stretch" = "Uzat" "Strikethrough" = "Üstü çizili" "String" = "Metin" @@ -1333,6 +1356,7 @@ name = "Türkçe" "Text" = "Metin" "Text align" = "Metin hizalaması" "Text content" = "Metin içeriği" +"Text size" = "Metin boyutu" "Text style" = "Metin stili" "Text styles" = "Metin stilleri" "Texts" = "Metinler" @@ -1363,6 +1387,7 @@ name = "Türkçe" "This server is open to the public: no key needed." = "Bu sunucu herkese açık: anahtar gerekmez." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Bu sunucu anahtarını istiyor: adrese ?key=… ekleyin (`figcraft-server key` anahtarı sunucunun makinesinde yazdırır)." "This server takes files only with its key: see Advanced." = "Bu sunucu dosyaları yalnızca anahtarıyla kabul ediyor: Gelişmiş'e bakın." +"Thumbs up" = "Başparmak yukarı" "Tidy up" = "Toparla" "Tile" = "Döşe" "Tile type" = "Döşeme türü" @@ -1379,6 +1404,7 @@ name = "Türkçe" "Top left radius" = "Sol üst yarıçap" "Top right radius" = "Sağ üst yarıçap" "Transform" = "Dönüştür" +"Triangle" = "Üçgen" "Triangle arrow" = "Üçgen ok" "Trigger" = "Tetikleyici" "True" = "Doğru" @@ -1403,7 +1429,7 @@ name = "Türkçe" "Unread" = "Okunmamış" "Unresolve" = "Çözülmedi yap" "Unsaved changes" = "Kaydedilmemiş değişiklikler" -"Unsaved changes in {names}. Close the window and lose them?" = "{names} içinde kaydedilmemiş değişiklikler var. Pencere kapatılıp bunlar kaybedilsin mi?" +"Unsaved changes in {names}. Save them before closing?" = "{names} içinde kaydedilmemiş değişiklikler var. Kapatmadan önce kaydedilsin mi?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin ({save}) veya atmak için sekmeyi {alt}-tıklamayla kapatın" "Unsaved changes: save first, or close again with {key} to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin veya atmak için {key} ile yeniden kapatın" "Up" = "Yukarı" @@ -1733,6 +1759,15 @@ name = "Türkçe" "arrange.roundToPixels" = "Piksellere yuvarla" "arrange.spacing" = "Aralık" "arrange.tidy" = "Toparla" +"board.connector" = "Bağlayıcı" +"board.ink" = "Keçeli kalem çizgisi" +"board.label" = "Bağlayıcı etiketi" +"board.nextSticky" = "Sonraki yapışkan not" +"board.setColor" = "Pano rengi" +"board.setStyle" = "Pano stili" +"board.shape" = "Metinli şekil" +"board.stamp" = "Damga" +"board.sticky" = "Yapışkan not" "bool.exclude" = "Seçimi hariç tut" "bool.flatten" = "Boolean'ı düzleştir" "bool.intersect" = "Seçimi kesiştir" @@ -1858,6 +1893,7 @@ name = "Türkçe" "file.import" = "İçe aktar" "file.moveTab" = "Sekmeyi taşı" "file.new" = "Yeni tasarım dosyası" +"file.newBoard" = "Yeni pano" "file.newFromTemplate" = "Şablondan yeni" "file.open" = "Aç" "file.openRecent" = "Son kullanılanı aç" @@ -2124,10 +2160,14 @@ name = "Türkçe" "text.weightUp" = "Yazı tipi kalınlığını artır" "tool.arrow" = "Ok" "tool.comment" = "Yorum" +"tool.connector" = "Bağlayıcı" "tool.ellipse" = "Elips" +"tool.eraser" = "Silgi" "tool.frame" = "Çerçeve" "tool.hand" = "El" +"tool.highlighter" = "Fosforlu kalem" "tool.line" = "Çizgi" +"tool.marker" = "Keçeli kalem" "tool.move" = "Taşı" "tool.pen" = "Kalem" "tool.pencil" = "Kurşun kalem" @@ -2139,9 +2179,12 @@ name = "Türkçe" "tool.scaleAnchor" = "Ölçekleme bağlantı noktası" "tool.section" = "Bölüm" "tool.set" = "Araç seç" +"tool.shape" = "Şekiller" "tool.shapeBuilder" = "Şekil oluşturucu" "tool.slice" = "Dilim" +"tool.stamp" = "Damga" "tool.star" = "Yıldız" +"tool.sticky" = "Yapışkan not" "tool.text" = "Metin" "transform.move" = "Taşı" "transform.moveTo" = "Şuraya taşı" @@ -2203,11 +2246,13 @@ name = "Türkçe" "vector.edit.tool" = "Vektör düzenleme aracı" "vector.edit.transform" = "Noktaları dönüştür" "vector.edit.widthPoint" = "Genişlik noktası" +"vector.fillRule" = "Dolgu kuralını ayarla" "vector.flatten" = "Düzleştir" "vector.join" = "Seçimi birleştir" "vector.offset" = "Yolu ötele" "vector.outlineStroke" = "Konturu ana hatlara dönüştür" "vector.reverse" = "Yol yönünü ters çevir" +"vector.reverseLoop" = "Döngüyü çevir" "vector.simplify" = "Vektörü sadeleştir" "vector.smoothJoin" = "Seçimi yumuşak birleştir" "version.autosave" = "Otomatik kayıt sürümü" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 94c91942..7add99c5 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -119,6 +119,7 @@ status = "draft — needs native review" "Archived" = "已归档" "Arrange" = "排列" "Arrow" = "箭头" +"Arrow at the end" = "末端箭头" "As typed" = "原样" "Asking the server…" = "正在询问服务器…" "Assets" = "资源" @@ -190,7 +191,9 @@ status = "draft — needs native review" "C or ⇧C" = "C 或 ⇧C" "CMYK colour" = "CMYK 颜色" "Can view" = "可查看" +"Check" = "对勾" "Checking the server…" = "正在检查服务器…" +"Chevron" = "箭头形" "Clear from this list" = "从此列表中清除" "Code or invite link" = "代码或邀请链接" "Color profile: {name}" = "颜色配置文件:{name}" @@ -236,7 +239,6 @@ status = "draft — needs native review" "Close tab" = "关闭标签页" "Close version history" = "关闭版本历史" "Close when clicking outside" = "点击外部时关闭" -"Close without saving" = "关闭但不保存" "Code" = "代码" "Code syntax" = "代码语法" "Collapse" = "折叠" @@ -263,6 +265,7 @@ status = "draft — needs native review" "Commits" = "提交" "Company" = "公司" "Compare changes" = "比较更改" +"Compare with" = "比较对象" "Compatibility notes" = "兼容性说明" "Completed" = "已完成" "Component" = "组件" @@ -278,6 +281,7 @@ status = "draft — needs native review" "Connected" = "已连接" "Connected to code: {from}" = "已关联代码:{from}" "Connection lost, reconnecting (attempt {attempt})…" = "连接已断开,正在重新连接(第 {attempt} 次尝试)…" +"Connector" = "连接线" "Constrain proportions" = "锁定比例" "Constraints" = "约束" "Content" = "内容" @@ -361,6 +365,7 @@ status = "draft — needs native review" "Description" = "描述" "Description (optional)" = "描述(可选)" "Design" = "设计" +"Design panel" = "设计面板" "Desktop" = "桌面" "Destination" = "目标" "Detach alias" = "分离别名" @@ -371,6 +376,7 @@ status = "draft — needs native review" "Detach variable" = "分离变量" "Details" = "详细" "Dev Mode" = "开发模式" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "开发模式仅用于检查:切换到设计 (Shift+D) 进行编辑" "Dev resources" = "开发资源" "Development" = "开发" "Device" = "设备" @@ -387,6 +393,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "水平间距分布" "Distribute vertical spacing" = "垂直间距分布" "Divider" = "分隔线" +"Don't Save" = "不保存" "Don't truncate" = "不截断" "Done" = "完成" "Done editing" = "完成编辑" @@ -417,6 +424,7 @@ status = "draft — needs native review" "Effect settings" = "效果设置" "Effect styles" = "效果样式" "Effects" = "效果" +"Elbow" = "折线" "Ellipse" = "椭圆" "Ellipses" = "椭圆" "Else" = "否则" @@ -547,6 +555,7 @@ status = "draft — needs native review" "Hanging list" = "悬挂列表" "Hanging punctuation" = "标点悬挂" "Hard light" = "强光" +"Heart" = "爱心" "Height" = "高度" "Help" = "帮助" "Help and resources" = "帮助和资源" @@ -557,6 +566,7 @@ status = "draft — needs native review" "Hide from publishing" = "发布时隐藏" "Hide section contents" = "隐藏分区内容" "Highlight layers on hover" = "悬停时高亮图层" +"Highlighter" = "荧光笔" "Highlights" = "高光" "Hold Alt" = "按住 Alt" "Hold Alt+Z" = "按住 Alt+Z" @@ -712,6 +722,7 @@ status = "draft — needs native review" "Mark as resolved" = "标为已解决" "Mark as unread" = "标为未读" "Mark as unresolved" = "标为未解决" +"Marker" = "马克笔" "Mask" = "蒙版" "Mass · Stiffness · Damping" = "质量 · 刚度 · 阻尼" "Match (empty: the whole name)" = "匹配(留空:整个名称)" @@ -768,6 +779,7 @@ status = "draft — needs native review" "Named instance" = "具名实例" "Navigate to" = "导航到" "Nested instance" = "嵌套实例" +"New board" = "新建白板" "New design file" = "新建设计文件" "Next frame" = "下一个画框" "Next or previous control in the focused region" = "聚焦区域中的下一个或上一个控件" @@ -830,6 +842,7 @@ status = "draft — needs native review" "Not read yet" = "尚未读取" "Not shared" = "未分享" "Note {n}" = "备注 {n}" +"Nothing changed since this version." = "自此版本以来没有任何更改。" "Nothing changed since this was marked ready for dev." = "自标记为可供开发以来没有任何更改。" "Nothing here yet. Set a frame's status to list it." = "这里还没有内容。设置画框的状态即可将其列出。" "Nothing online yet. Use Share on a file to put it online and it is listed here." = "还没有在线文件。对文件使用“分享”将其放到网上后,它会列在这里。" @@ -863,6 +876,7 @@ status = "draft — needs native review" "Open branch" = "打开分支" "Open file" = "打开文件" "Open files" = "打开的文件" +"Open presentations" = "打开的演示" "Open in focus view" = "在聚焦视图中打开" "Open in new window" = "在新窗口中打开" "Open invite links in FigCraft" = "在 FigCraft 中打开邀请链接" @@ -897,6 +911,7 @@ status = "draft — needs native review" "Page deleted" = "页面已删除" "Pages" = "页面" "Pages added to the document" = "页面已添加到文档" +"Pink" = "粉色" "Placed on the current page" = "已放置在当前页面" "Pages: {pages}" = "页面:{pages}" "Paint bucket" = "油漆桶" @@ -938,6 +953,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "插件在 FigCraft 的沙盒中运行,无法使用文件、网络或时钟。请只安装你信任的插件。" "Plus darker" = "加深" "Plus lighter" = "增亮" +"Plus one" = "加一" "Point" = "点" "Points" = "点数" "Polygon" = "多边形" @@ -984,6 +1000,7 @@ status = "draft — needs native review" "Push" = "推入" "Push changes to main component" = "将更改推送到主组件" "Put text on path" = "将文本放到路径上" +"Question" = "问号" "R" = "R" "Radial" = "径向" "Radius" = "半径" @@ -1028,6 +1045,7 @@ status = "draft — needs native review" "Removed" = "已删除" "Removed from the server" = "已从服务器移除" "Removed since it was marked" = "标记后已删除" +"Removed since this version" = "自此版本以来已移除" "Rename" = "重命名" "Rename duplicated layers" = "重命名副本图层" "Rename folder" = "重命名文件夹" @@ -1083,6 +1101,7 @@ status = "draft — needs native review" "Rotation jitter" = "旋转抖动" "Round" = "圆形" "Rounded" = "圆润" +"Rounded rectangle" = "圆角矩形" "Row" = "行" "Row gap" = "行间距" "Row sizes" = "行高" @@ -1152,6 +1171,7 @@ status = "draft — needs native review" "Shape" = "形状" "Shape builder" = "形状生成器" "Shape tools" = "形状工具" +"Shapes" = "形状" "Share" = "分享" "Share this file" = "分享此文件" "Share: live session, export" = "分享:实时会话、导出" @@ -1209,10 +1229,13 @@ status = "draft — needs native review" "Space+drag" = "空格+拖动" "Spacing" = "间距" "Spam, stolen work, abuse…" = "垃圾信息、盗用作品、滥用…" +"Speech bubble" = "对话气泡" "Splay" = "张开" "Spotlight me" = "聚焦于我" "Spread" = "扩展" "Square" = "方形" +"Stamp" = "印章" +"Stamps" = "印章" "Standard" = "标准" "Standard ligatures" = "标准连字" "Star" = "星形" @@ -1227,12 +1250,16 @@ status = "draft — needs native review" "Starting to share…" = "正在开始分享…" "Status" = "状态" "Sticky" = "粘性" +"Sticky note" = "便利贴" +"Sticky note colors" = "便利贴颜色" +"Sticky notes, shapes and connectors" = "便利贴、形状和连接线" "Stop" = "停止" "Stop following {name}" = "停止跟随 {name}" "Stop session" = "停止会话" "Stop sharing" = "停止分享" "Stop spotlighting" = "停止聚焦" "Stop {n} {hex}" = "色标 {n} {hex}" +"Straight" = "直线" "Stretch" = "拉伸" "Strikethrough" = "删除线" "String" = "字符串" @@ -1276,6 +1303,7 @@ status = "draft — needs native review" "Text" = "文本" "Text align" = "文本对齐" "Text content" = "文本内容" +"Text size" = "文字大小" "Text style" = "文本样式" "Text styles" = "文本样式" "Texts" = "文本" @@ -1306,6 +1334,9 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "此服务器对公众开放:无需密钥。" "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "此服务器需要密钥:请在地址后加上 ?key=…(在服务器所在的机器上运行 `figcraft-server key` 可显示)。" "This server takes files only with its key: see Advanced." = "此服务器只接受带密钥的文件:请查看“高级”。" +"Thumbs up" = "点赞" +"This version can't be read." = "无法读取此版本。" +"This version doesn't have it: all of it is new." = "此版本中没有它:全部是新的。" "Tidy up" = "整理" "Tile" = "平铺" "Tile type" = "平铺类型" @@ -1322,6 +1353,7 @@ status = "draft — needs native review" "Top left radius" = "左上圆角" "Top right radius" = "右上圆角" "Transform" = "变换" +"Triangle" = "三角形" "Triangle arrow" = "三角箭头" "Trigger" = "触发器" "True" = "真" @@ -1344,7 +1376,7 @@ status = "draft — needs native review" "Unread" = "未读" "Unresolve" = "取消解决" "Unsaved changes" = "未保存的更改" -"Unsaved changes in {names}. Close the window and lose them?" = "{names} 中有未保存的更改。要关闭窗口并丢弃这些更改吗?" +"Unsaved changes in {names}. Save them before closing?" = "{names} 中有未保存的更改。关闭前要保存吗?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "有未保存的更改:请先保存({save}),或按住 {alt} 点击关闭标签页以丢弃" "Unsaved changes: save first, or close again with {key} to discard" = "有未保存的更改:请先保存,或再次按 {key} 关闭以丢弃" "Up" = "向上" @@ -1407,10 +1439,12 @@ status = "draft — needs native review" "WebSocket URL copied" = "已复制 WebSocket URL" "Wedge" = "楔形" "Weight" = "粗细" +"What changed since a saved version" = "自已保存版本以来的更改" "What changed since it was marked ready for dev" = "自标记为可供开发以来的更改" "What is wrong with this file? The server's operator gets your report." = "此文件有什么问题?你的举报会发送给服务器的运营者。" "What it's for" = "用途" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "无法得知更改前的状态:它在 FigCraft 开始保存可供开发画框的副本之前就已更改。请解决此更改(或重新标记为可供开发),以便今后进行比较。" +"When marked ready" = "标记为就绪时" "While hovering" = "悬停时" "While pressing" = "按住时" "White" = "白" @@ -1669,6 +1703,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "取整到像素" "arrange.spacing" = "间距" "arrange.tidy" = "整理" +"board.connector" = "连接线" +"board.ink" = "马克笔笔画" +"board.label" = "连接线标签" +"board.nextSticky" = "下一张便利贴" +"board.setColor" = "白板颜色" +"board.setStyle" = "白板样式" +"board.shape" = "带文字的形状" +"board.stamp" = "印章" +"board.sticky" = "便利贴" "bool.exclude" = "排除所选项" "bool.flatten" = "拼合布尔组" "bool.intersect" = "所选项相交" @@ -1793,6 +1836,7 @@ status = "draft — needs native review" "file.import" = "导入" "file.moveTab" = "移动标签页" "file.new" = "新建设计文件" +"file.newBoard" = "新建白板" "file.open" = "打开" "file.openRecent" = "打开最近的文件" "file.placeImage" = "置入图片" @@ -2056,10 +2100,14 @@ status = "draft — needs native review" "text.weightUp" = "增大字重" "tool.arrow" = "箭头" "tool.comment" = "评论" +"tool.connector" = "连接线" "tool.ellipse" = "椭圆" +"tool.eraser" = "橡皮擦" "tool.frame" = "画框" "tool.hand" = "抓手" +"tool.highlighter" = "荧光笔" "tool.line" = "直线" +"tool.marker" = "马克笔" "tool.move" = "移动" "tool.pen" = "钢笔" "tool.pencil" = "铅笔" @@ -2071,9 +2119,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "缩放锚点" "tool.section" = "分区" "tool.set" = "选择工具" +"tool.shape" = "形状" "tool.shapeBuilder" = "形状生成器" "tool.slice" = "切片" +"tool.stamp" = "印章" "tool.star" = "星形" +"tool.sticky" = "便利贴" "tool.text" = "文本" "transform.move" = "移动" "transform.moveTo" = "移动到" @@ -2135,11 +2186,13 @@ status = "draft — needs native review" "vector.edit.tool" = "矢量编辑工具" "vector.edit.transform" = "变换点" "vector.edit.widthPoint" = "宽度点" +"vector.fillRule" = "设置填充规则" "vector.flatten" = "拼合" "vector.join" = "连接所选项" "vector.offset" = "偏移路径" "vector.outlineStroke" = "轮廓化描边" "vector.reverse" = "反转路径方向" +"vector.reverseLoop" = "反转环" "vector.simplify" = "简化矢量" "vector.smoothJoin" = "平滑连接所选项" "version.autosave" = "自动保存版本" diff --git a/crates/ui-egui/src/board_bar.rs b/crates/ui-egui/src/board_bar.rs new file mode 100644 index 00000000..c312ddb9 --- /dev/null +++ b/crates/ui-egui/src/board_bar.rs @@ -0,0 +1,294 @@ +//! Boards in the UI: the small bar floating over a board's selection (colour, shape, text size, +//! connector style; boards hide the Design panel until asked for), and the swatches it and the +//! board tools' flyouts share. + +use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_doc::{BOARD_COLORS, BoardItem, BoardShape, NodeKind, Routing, Stamp, StrokeCap}; +use figcraft_engine::board::{BoardTool, TEXT_SIZES}; +use serde_json::{Value, json}; + +use crate::FigApp; +use crate::a11y; +use crate::i18n::{tr, tr_noop}; +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, semibold}; +use crate::toolbar::Options; + +/// What a swatch shows. +pub(crate) enum Mark { + Color(figcraft_color::Color), + Shape(BoardShape), + Stamp(Stamp), +} + +/// One choice of a board tool's flyout: its name, the tool's params, whether it is the tool's now. +pub(crate) struct Choice { + pub label: &'static str, + pub params: Value, + pub on: bool, + pub mark: Mark, +} + +/// The choices a board tool's flyout offers. +pub(crate) fn choices(options: Options, board: &BoardTool) -> Vec { + match options { + Options::Colors => BOARD_COLORS + .iter() + .map(|(n, c)| Choice { label: color_label(n), params: json!({"color": n}), on: board.sticky == *c, mark: Mark::Color(*c) }) + .collect(), + Options::Shapes => BoardShape::ALL + .iter() + .map(|b| Choice { label: b.label(), params: json!({"shape": b.id()}), on: board.shape == *b, mark: Mark::Shape(*b) }) + .collect(), + Options::Stamps => Stamp::ALL + .iter() + .map(|b| Choice { label: b.label(), params: json!({"stamp": b.id()}), on: board.stamp == *b, mark: Mark::Stamp(*b) }) + .collect(), + Options::None => Vec::new(), + } +} + +/// A board colour's name as the UI says it. +fn color_label(name: &str) -> &'static str { + match name { + "yellow" => tr_noop("Yellow"), + "orange" => tr_noop("Orange"), + "red" => tr_noop("Red"), + "pink" => tr_noop("Pink"), + "violet" => tr_noop("Violet"), + "blue" => tr_noop("Blue"), + "green" => tr_noop("Green"), + _ => tr_noop("Gray"), + } +} + +/// Paint `mark` in `r`: a colour chip, a shape's outline in `ink`, or a stamp badge. +pub(crate) fn paint_choice(p: &egui::Painter, r: Rect, mark: &Mark, ink: Color32) { + match mark { + Mark::Color(c) => { + p.rect_filled(r, CornerRadius::same(4), c32(*c)); + p.rect_stroke(r, CornerRadius::same(4), Stroke::new(1.0, Color32::from_black_alpha(40)), StrokeKind::Inside); + } + Mark::Shape(s) => { + let path = s.path(14.0, 14.0); + let path = figcraft_geom::Affine::translate((1.0, 1.0)) * path; + icons::paint_outlines(p, r, 16.0, &[(&path, false)], ink, 1.2); + } + Mark::Stamp(s) => { + p.circle_filled(r.center(), r.width().min(r.height()) / 2.0, c32(s.color())); + let parts: Vec<(figcraft_geom::BezPath, bool)> = + s.mark().iter().filter_map(|m| figcraft_geom::BezPath::from_svg(m.d).ok().map(|b| (b, m.filled))).collect(); + let refs: Vec<(&figcraft_geom::BezPath, bool)> = parts.iter().map(|(b, f)| (b, *f)).collect(); + icons::paint_outlines(p, r, figcraft_doc::STAMP_SIZE, &refs, Color32::WHITE, 3.0); + } + } +} + +fn c32(c: figcraft_color::Color) -> Color32 { + let [r, g, b, a] = c.to_rgba8(); + Color32::from_rgba_unmultiplied(r, g, b, a) +} + +/// What the selection has that the bar can change. +#[derive(Default)] +struct Has { + color: bool, + shape: Option, + text: bool, + connector: Option<(Routing, bool)>, +} + +/// What the selected board objects take from the bar (layers that aren't board objects, a section +/// say, are left out); `None` when none takes anything. +fn has(app: &FigApp) -> Option { + let ds = app.session.ds().ok()?; + let mut h = Has::default(); + for n in ds.selection.iter().filter_map(|id| ds.doc.node(*id)) { + match &n.board { + Some(BoardItem::Sticky { .. } | BoardItem::Ink) => h.color = true, + Some(BoardItem::Shape { shape }) => { + h.color = true; + h.text = true; + h.shape = Some(*shape); + } + Some(BoardItem::Connector(c)) => { + h.color = true; + h.connector = Some((c.routing, n.stroke.cap_end != StrokeCap::None)); + } + Some(BoardItem::Label { .. }) => h.text = true, + Some(BoardItem::Stamp { .. }) | None => {} + } + } + (h.color || h.text || h.connector.is_some()).then_some(h) +} + +const CELL: f32 = 28.0; +const SWATCH: f32 = 20.0; +const GAP: f32 = 4.0; +/// The room each part takes after its controls (the divider and the gap around it). +const PART_END: f32 = 8.0; + +impl Has { + /// The bar's width for these parts. + fn width(&self) -> f32 { + let mut w = 8.0; + if self.color { + w += BOARD_COLORS.len() as f32 * (SWATCH + GAP) + PART_END; + } + if self.shape.is_some() { + w += CELL + 12.0 + GAP + PART_END; + } + if self.text { + w += TEXT_SIZES.len() as f32 * (CELL + 2.0) + PART_END; + } + if self.connector.is_some() { + w += 3.0 * (CELL + 2.0) + PART_END; + } + w + } +} + +/// Where a bar `size` big goes for the selection's screen box `sel`: centred above it, below it +/// when there is no room above, inside the canvas. +pub(crate) fn place(sel: Rect, size: egui::Vec2, canvas: Rect) -> Pos2 { + let w = size.x; + let cx = sel.center().x.clamp(canvas.min.x + w / 2.0 + 8.0, (canvas.max.x - w / 2.0 - 8.0).max(canvas.min.x + w / 2.0 + 8.0)); + let above = sel.min.y - 14.0 - size.y; + let y = if above > canvas.min.y + 8.0 { above } else { (sel.max.y + 14.0).min(canvas.max.y - size.y - 80.0) }; + pos2(cx - w / 2.0, y) +} + +/// The bar above a board's selection, while nothing is being dragged and the file can be edited. +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + let editable = app.session.active_read_only().is_none(); + if app.session.tool.busy() || !editable || !app.session.doc().is_ok_and(figcraft_engine::board::is_board) { + return; + } + let Some(h) = has(app) else { return }; + let Some(b) = app.session.ds().ok().and_then(|ds| ds.doc.bounds_of(&ds.selection)) else { return }; + let sel = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + let size = vec2(h.width(), 40.0); + let at = place(sel, size, app.canvas.rect); + let t = Tokens::get(ctx); + egui::Area::new(egui::Id::new("board_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(size, Sense::click()); + a11y::container_bounds(ui, rect); + let p = ui.painter(); + p.rect_filled(rect.expand(2.0).translate(vec2(0.0, 1.0)), CornerRadius::same(12), Color32::from_black_alpha(18)); + p.rect_filled(rect, CornerRadius::same(10), t.panel); + p.rect_stroke(rect, CornerRadius::same(10), Stroke::new(1.0, t.border), StrokeKind::Inside); + let cy = rect.center().y; + let mut x = rect.min.x + 8.0; + if h.color { + x = color_part(app, ui, x, cy, &t); + } + if let Some(shape) = h.shape { + x = shape_part(app, ui, shape, pos2(x, cy), &t); + } + if h.text { + x = size_part(app, ui, x, cy, &t); + } + if let Some((routing, arrow)) = h.connector { + line_part(app, ui, (routing, arrow), pos2(x, cy), &t); + } + }); +} + +/// The board colours from `x` (`board.setColor`); where the next part starts. +fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { + for (i, (name, c)) in BOARD_COLORS.iter().enumerate() { + let r = Rect::from_center_size(pos2(x + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH)); + let resp = ui.interact(r, ui.id().with(("bb_color", i)), Sense::click()).on_hover_text(tr(color_label(name))); + a11y::button(&resp, tr(color_label(name))); + if resp.hovered() { + ui.painter().rect_filled(r.expand(2.0), CornerRadius::same(6), t.hover); + } + paint_choice(ui.painter(), r, &Mark::Color(*c), t.icon); + if resp.clicked() { + app.run("board.setColor", json!({"color": name})); + } + x += SWATCH + GAP; + } + divider(ui, t, x, cy) +} + +/// The shape menu at `at` (its left edge and middle), showing `shape`; where the next part starts. +fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &Tokens) -> f32 { + let r = Rect::from_min_size(pos2(at.x, at.y - CELL / 2.0), vec2(CELL + 12.0, CELL)); + let resp = ui.interact(r, ui.id().with("bb_shape"), Sense::click()).on_hover_text(tr("Shape")); + a11y::menu_button(&resp, tr("Shape"), a11y::menu_open(&resp)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + paint_choice(ui.painter(), Rect::from_center_size(pos2(r.min.x + CELL / 2.0, at.y), vec2(18.0, 18.0)), &Mark::Shape(shape), t.icon); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 7.0, at.y), vec2(12.0, 12.0)), Icon::Chevron, t.icon_secondary); + crate::right::menu_popup(&resp).show(|ui| { + for b in BoardShape::ALL { + let art = |p: &egui::Painter, row: Rect, ink: Color32| { + paint_choice(p, Rect::from_center_size(pos2(row.max.x - 16.0, row.center().y), vec2(16.0, 16.0)), &Mark::Shape(b), ink) + }; + if crate::right::menu_item_with(ui, tr(b.label()), "", 24.0, art) { + app.run("board.setStyle", json!({"shape": b.id()})); + ui.close(); + } + } + }); + divider(ui, t, at.x + CELL + 12.0 + GAP, at.y) +} + +/// The text sizes from `x` (`board.setStyle {fontSize}`); where the next part starts. +fn size_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { + for (label, size) in TEXT_SIZES { + let r = Rect::from_center_size(pos2(x + CELL / 2.0, cy), vec2(CELL, CELL)); + let resp = ui.interact(r, ui.id().with(("bb_size", label)), Sense::click()).on_hover_text(tr("Text size")); + a11y::button(&resp, &format!("{} {label}", tr("Text size"))); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, semibold(11.0), t.text); + if resp.clicked() { + app.run("board.setStyle", json!({"fontSize": size})); + } + x += CELL + 2.0; + } + divider(ui, t, x, cy) +} + +/// A connector's routing (straight, elbow) and end arrow at `at`, as they are now (`line`). +fn line_part(app: &mut FigApp, ui: &mut Ui, line: (Routing, bool), at: Pos2, t: &Tokens) { + let (routing, arrow) = line; + let cells = [ + (Icon::Line, tr("Straight"), routing == Routing::Straight, json!({"style": "straight"})), + (Icon::Connector, tr("Elbow"), routing == Routing::Elbow, json!({"style": "elbow"})), + (Icon::Arrow, tr("Arrow at the end"), arrow, json!({"endCap": if arrow { "none" } else { "triangleArrow" }})), + ]; + let mut x = at.x; + for (i, (icon, tip, on, params)) in cells.into_iter().enumerate() { + let r = Rect::from_center_size(pos2(x + CELL / 2.0, at.y), vec2(CELL, CELL)); + if crate::toolbar::tool_cell(ui, t, r, ("bb_line", i), icon, on, tip).clicked() { + app.run("board.setStyle", params); + } + x += CELL + 2.0; + } +} + +/// A thin separator after `x`; where the next part starts. +fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) -> f32 { + ui.painter().line_segment([pos2(x + 2.0, cy - 10.0), pos2(x + 2.0, cy + 10.0)], Stroke::new(1.0, t.border)); + x + 8.0 +} + +/// The name of the person who placed the stamp under the pointer, beside it. +pub fn stamp_author(app: &FigApp, painter: &egui::Painter, pointer: Option) { + let (Some(at), Ok(ds)) = (pointer, app.session.ds()) else { return }; + let Some(n) = ds.hover.and_then(|h| ds.doc.node(h)) else { return }; + let Some(BoardItem::Stamp { author, .. }) = &n.board else { return }; + if author.is_empty() || !matches!(n.kind, NodeKind::Frame) { + return; + } + let t = Tokens::get(painter.ctx()); + let galley = painter.layout_no_wrap(author.clone(), semibold(11.0), t.text); + let r = Rect::from_min_size(at + vec2(14.0, 14.0), galley.size() + vec2(12.0, 6.0)); + painter.rect_filled(r, CornerRadius::same(4), t.menu); + painter.galley(r.min + vec2(6.0, 3.0), galley, t.menu_text); +} diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs new file mode 100644 index 00000000..4ffb3cf4 --- /dev/null +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -0,0 +1,212 @@ +//! Boards in the UI, drawn headless: the board toolbar (its flyouts pick a tool with a colour, a +//! shape or a stamp; the Design panel's button), the right panel hidden until asked for, the bar +//! over the selection (colours, shapes, text sizes, connector style, also with layers that aren't +//! board objects selected, never on a file you can only view), the home screen's New board card, +//! and the marks design files and boards get. + +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2}; +use figcraft_doc::{BoardItem, BoardShape, FileKind, NodeId, Routing, Stamp, StrokeCap, board_color}; +use figcraft_engine::Session; +use figcraft_engine::tools::ToolId; +use serde_json::{Value, json}; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, has_text, text_center}; + +fn click(u: &mut Ui, p: Pos2) { + for pressed in [true, false] { + u.frame(vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + u.frame(vec![]); +} + +fn id(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// The app on a new board. +fn board() -> Ui { + let mut s = Session::new(); + s.execute("file.newBoard", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + u +} + +/// The board toolbar pill on screen. +fn pill(u: &Ui) -> Rect { + crate::toolbar::pill_rect(u.app.canvas.rect, crate::toolbar::bar_width(&u.app)) +} + +/// The left edge of board toolbar group `i` (button 32 wide, a 10-wide caret after the groups with +/// flyouts, 4 between): move, ink, sticky, shapes, connector, text, section, stamp, comment. +fn group_x(u: &Ui, i: usize) -> f32 { + const WIDTHS: [f32; 9] = [46.0, 46.0, 46.0, 46.0, 36.0, 36.0, 36.0, 46.0, 36.0]; + pill(u).min.x + 8.0 + WIDTHS.iter().take(i).sum::() +} + +/// Open group `i`'s flyout from its caret and click option `n` in it (a row of 32-wide cells, 4 +/// apart, 8 in from the flyout's edge, the flyout's bottom 8 above the pill). +fn pick_option(u: &mut Ui, i: usize, n: usize) { + let caret = pos2(group_x(u, i) + 37.0, pill(u).center().y); + click(u, caret); + let at = pos2(group_x(u, i) + 8.0 + n as f32 * 36.0 + 16.0, pill(u).min.y - 8.0 - 24.0); + click(u, at); +} + +/// Whether the bar over the selection showed in the last frame. +fn bar_shown(ctx: &egui::Context) -> bool { + ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Middle, egui::Id::new("board_bar")))) +} + +/// The bar over the selection, when it shows. +fn bar(u: &Ui) -> Option { + bar_shown(&u.ctx).then(|| u.ctx.memory(|m| m.area_rect(egui::Id::new("board_bar")))).flatten() +} + +#[test] +fn the_board_toolbar_picks_tools_with_their_colours_shapes_and_stamps() { + let mut u = board(); + pick_option(&mut u, 2, 5); + assert_eq!(u.app.session.tool.tool, ToolId::Sticky); + assert_eq!(Some(u.app.session.tool.board.sticky), board_color("blue")); + pick_option(&mut u, 3, 3); + assert_eq!((u.app.session.tool.tool, u.app.session.tool.board.shape), (ToolId::BoardShape, BoardShape::Diamond)); + pick_option(&mut u, 7, 1); + assert_eq!((u.app.session.tool.tool, u.app.session.tool.board.stamp), (ToolId::Stamp, Stamp::Heart)); + // The connector's button picks it; the ink group's flyout lists the marker's siblings. + let connector = pos2(group_x(&u, 4) + 16.0, pill(&u).center().y); + click(&mut u, connector); + assert_eq!(u.app.session.tool.tool, ToolId::Connector); + let ink_caret = pos2(group_x(&u, 1) + 37.0, pill(&u).center().y); + click(&mut u, ink_caret); + assert_eq!(u.app.ui.tool_flyout, Some("ink")); +} + +#[test] +fn a_boards_design_panel_opens_when_asked_for_and_prototyping_is_not_offered() { + let mut u = board(); + u.app.ui.right_tab = crate::RightTab::Prototype; + u.frame(vec![]); + assert_eq!(u.app.ui.right_tab, crate::RightTab::Design, "boards have no Prototype tab"); + let canvas = u.app.canvas.rect; + assert!(canvas.max.x > 1400.0, "no right panel: the canvas reaches the window's edge ({canvas:?})"); + // The Design panel's button at the pill's right end. + let p = pill(&u); + click(&mut u, pos2(p.max.x - 8.0 - 16.0, p.center().y)); + assert!(u.app.ui.board_panel); + u.frame(vec![]); + assert!(u.app.canvas.rect.max.x < canvas.max.x - 200.0, "the panel takes its room"); + // On a design file the panel shows whatever was asked for a board. + u.app.run("file.new", json!({})); + u.app.run("view.design", json!({})); + u.frame(vec![]); + assert!(u.app.ui.board_panel, "view.design on a design file doesn't toggle the board's panel"); +} + +#[test] +fn the_selection_bar_recolours_reshapes_and_restyles() { + let mut u = board(); + let shape = id(&u.app.session.execute("board.shape", &json!({"x": 0, "y": 0, "shape": "rectangle", "text": "Hi"})).unwrap()); + let section = id(&u.app.session.execute("node.create", &json!({"type": "section", "x": 0, "y": 400, "width": 200, "height": 200})).unwrap()); + // A section selected with it (no board object) leaves the bar for the shape. + u.app.session.execute("select.set", &json!({"ids": [shape, section]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let r = bar(&u).expect("the bar shows"); + let cy = r.center().y; + // Swatch 5 (blue): 8 in, 24 apart, 20 wide. + click(&mut u, pos2(r.min.x + 8.0 + 5.0 * 24.0 + 10.0, cy)); + let outline = u.app.session.doc().unwrap().node(shape).unwrap().children[0]; + assert_eq!(u.app.session.doc().unwrap().node(outline).unwrap().fills[0].solid_color(), board_color("blue")); + // Text size L: after the colours (208) and the shape menu (52), the third of the 30-wide cells. + let r = bar(&u).unwrap(); + click(&mut u, pos2(r.min.x + 208.0 + 52.0 + 2.0 * 30.0 + 14.0, cy)); + let text = figcraft_engine::board::text_of(u.app.session.doc().unwrap(), shape).unwrap(); + assert_eq!(u.app.session.doc().unwrap().node(text).unwrap().kind.text().unwrap().style.font_size, 24.0); + // The shape menu lists the outlines. + let r = bar(&u).unwrap(); + click(&mut u, pos2(r.min.x + 208.0 + 20.0, cy)); + let shapes = flat(&u.frame_output(vec![]).shapes); + click(&mut u, text_center(&shapes, "Ellipse")); + assert_eq!(u.app.session.doc().unwrap().node(shape).unwrap().board, Some(BoardItem::Shape { shape: BoardShape::Ellipse })); +} + +#[test] +fn the_selection_bar_sets_a_connectors_style_and_offers_nothing_for_a_stamp() { + let mut u = board(); + let a = u.app.session.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone(); + let b = u.app.session.execute("board.sticky", &json!({"x": 400, "y": 300})).unwrap()["id"].clone(); + let c = id(&u.app.session.execute("board.connector", &json!({"from": a, "to": b, "style": "elbow"})).unwrap()); + u.frame(vec![]); + u.frame(vec![]); + let r = bar(&u).expect("the bar shows for a connector"); + let cy = r.center().y; + // After the colours (208): Straight, Elbow, the end's arrow, 30 apart. + click(&mut u, pos2(r.min.x + 208.0 + 14.0, cy)); + let routing = |u: &Ui| match &u.app.session.doc().unwrap().node(c).unwrap().board { + Some(BoardItem::Connector(k)) => k.routing, + _ => panic!("not a connector"), + }; + assert_eq!(routing(&u), Routing::Straight); + let r = bar(&u).unwrap(); + click(&mut u, pos2(r.min.x + 208.0 + 60.0 + 14.0, cy)); + assert_eq!(u.app.session.doc().unwrap().node(c).unwrap().stroke.cap_end, StrokeCap::None); + // Nothing is offered while a stamp alone is selected: it takes neither colour nor style. + let st = id(&u.app.session.execute("board.stamp", &json!({"x": 900, "y": 0, "stamp": "star"})).unwrap()); + u.app.session.execute("select.set", &json!({"ids": [st]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + assert!(bar(&u).is_none()); +} + +#[test] +fn a_board_you_only_view_offers_no_bar() { + let (mut h, _) = crate::live_tests::Harness::new(); + h.app.session.execute("file.newBoard", &json!({})).unwrap(); + let st = id(&h.app.session.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()); + h.frames(2); + assert!(bar_shown(&h.ctx), "the bar shows for the selected sticky"); + crate::live_server_tests::joined(&mut h, figcraft_engine::live::Access::View); + h.app.session.execute("select.set", &json!({"ids": [st]})).unwrap(); + h.frames(2); + assert!(h.app.session.active_read_only().is_some()); + assert!(!bar_shown(&h.ctx), "view only: nothing to edit"); +} + +#[test] +fn the_home_screen_makes_boards_and_marks_files_by_kind() { + let mut u = Ui::new(Session::empty()); + let shapes = flat(&u.frame_output(vec![]).shapes); + click(&mut u, text_center(&shapes, "New board")); + assert_eq!(u.app.session.doc().unwrap().kind, FileKind::Board); + assert_eq!(crate::icons::file_mark(FileKind::Board), crate::icons::Icon::Board); + assert_eq!(crate::icons::file_mark(FileKind::Design), crate::icons::Icon::PenNib); +} + +#[test] +fn a_stamp_shows_who_placed_it_on_hover() { + let mut s = Session::new(); + s.prefs.user_name = "Grace".into(); + s.execute("file.newBoard", &json!({})).unwrap(); + s.execute("board.stamp", &json!({"x": 100, "y": 100, "stamp": "check"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let at = u.app.to_screen(100.0, 100.0); + u.frame(vec![Event::PointerMoved(at)]); + u.frame(vec![Event::PointerMoved(at + egui::vec2(1.0, 0.0))]); + let shapes = flat(&u.frame_output(vec![Event::PointerMoved(at + egui::vec2(1.0, 1.0))]).shapes); + assert!(has_text(&shapes, "Grace"), "the placer's name beside the pointer"); +} + +#[test] +fn the_next_sticky_from_the_keyboard_goes_on_typing() { + let mut u = board(); + u.app.run("board.sticky", json!({"x": 0, "y": 0})); + // Shift+Cmd/Ctrl+Enter runs it from the menu layer, asking to type in the new one. + crate::menus::invoke(&mut u.app, "board.nextSticky"); + let doc = u.app.session.doc().unwrap(); + let te = u.app.session.ds().unwrap().text_edit.clone().expect("typing in the new sticky"); + let owner = figcraft_engine::board::owner_of_text(doc, te.id).unwrap(); + assert_eq!(doc.node(owner).unwrap().transform.translation().x, 224.0); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 46451ae0..0b13f5a8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -256,6 +256,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } crate::dev_mode::draw(app, &painter); crate::handles::draw(app, &painter); + crate::board_bar::stamp_author(app, &painter, ctx.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p))); + crate::board_bar::show(app, &ctx); crate::selection_spacing::draw(app, &painter); crate::comments::draw(app, &painter); crate::live_text::draw(app, &painter); @@ -939,7 +941,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } } // The focus view's bar names its frame. - let labelled = focus.is_none() && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })); + // Board objects (stickies, shapes with text, stamps) are frames without a name label. + let labelled = focus.is_none() && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) && !n.is_board_atom(); if labelled && let Some(q) = crate::frame_labels::draw(app, painter, t, &doc, *c, sel.contains(c), ds.hover == Some(*c)) { labels.push((*c, q.map(|p| app.to_page(p)))); } @@ -1044,8 +1047,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX; // Measuring to another layer (Alt held) shows just the outlines: no handles or size. let measuring = app.session.tool.overlay.measure_box.is_some(); - // Locked layers (selected in the layers panel) can't be resized: no handles either. - let handles = if measuring || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { + // Locked layers (selected in the layers panel) can't be resized, nor anything in Dev + // Mode: no handles either. + let handles = if measuring || app.session.tool.dev_mode || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { 0 } else if is_line { 2 diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index d6aad31d..9d37477a 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -435,8 +435,21 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { slider_knob(ui, rect, color.a, Id::new("cp_alpha")) } +/// The colour picker window's area id. A fixed id (not `Window::new`'s, which egui keys by +/// `Id::new(Some(title))`) so the style picker can tell a click inside the picker from one outside. +pub fn window_id() -> Id { + Id::new("color_picker") +} + pub fn show(app: &mut FigApp, ctx: &egui::Context) { - let Some(target) = app.ui.picker.clone() else { return }; + let Some(target) = app.ui.picker.clone() else { + // A colour sampled for a picker that has closed has no paint to go to. + app.session.tool.picked = None; + if app.session.tool.pick_only { + app.run("tool.move", json!({})); + } + return; + }; // A layer's picker belongs to the selection it was opened for: once that changes, close it // rather than keep editing layers that are no longer selected. let stale = match &target { @@ -463,6 +476,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut open = true; let mut close_now = false; egui::Window::new("color_picker") + // A fixed area id so the style picker can tell a click in here from a click outside + // (`Window::new` alone would key the area by `Id::new(Some(title))`). + .id(window_id()) .title_bar(false) .fixed_pos(pos) .fixed_size(vec2(240.0, 0.0)) @@ -470,6 +486,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { .open(&mut open) .show(ctx, |ui| { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); + // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...). + if let Some(c) = app.session.tool.picked.take() { + set_color(app, &target, &mut paint, c, stop_id(), ui); + } // Header. let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text); @@ -772,8 +792,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ) .clicked() { - app.ui.picker = None; - app.run("edit.pickColor", json!({})); + app.run("edit.pickColor", json!({"apply": false})); return; } let (c2, d2) = hue_slider(ui, hr, &mut hsb); diff --git a/crates/ui-egui/src/colorpicker_eyedropper_tests.rs b/crates/ui-egui/src/colorpicker_eyedropper_tests.rs new file mode 100644 index 00000000..51856b48 --- /dev/null +++ b/crates/ui-egui/src/colorpicker_eyedropper_tests.rs @@ -0,0 +1,90 @@ +//! The eyedropper of a colour picker, driven through egui headless: the colour it samples goes to +//! the paint the picker edits (a stroke, an effect), not to the layer's fill (vectorcraft#3342). + +use egui::accesskit::Role; +use egui::{Event, Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::{Session, ToolId}; +use serde_json::json; + +use crate::colorpicker::PickerTarget; +use crate::layers_drag_tests::Ui; + +fn rect(s: &mut Session, x: f64, props: serde_json::Value) -> NodeId { + let v = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 60, "height": 60, "props": props})).unwrap(); + NodeId(v["id"].as_u64().unwrap()) +} + +/// The middle of the picker's eyedropper button. +fn eyedropper_button(u: &mut Ui) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some("Pick color (I)")).expect("the eyedropper button"); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![]); +} + +#[test] +fn the_pickers_eyedropper_applies_the_colour_to_the_paint_it_edits() { + for effect in [false, true] { + // A red layer to sample, and a green one with a blue stroke (and a shadow) to edit. + let mut s = Session::new(); + s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap(); + let red = rect(&mut s, 0.0, json!({"fill": "#FF0000"})); + let b = rect(&mut s, 200.0, json!({"fill": "#00FF00", "stroke": "#0000FF", "strokeWeight": 2})); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let target = if effect { + s.execute("effect.add", &json!({"ids": [b], "type": "dropShadow"})).unwrap(); + PickerTarget::Effect { ids: vec![b], index: 0, secondary: false } + } else { + PickerTarget::Paint { fills: false, index: 0, ids: vec![b] } + }; + let mut u = Ui::new(s); + u.app.ui.picker = Some(target.clone()); + let at = eyedropper_button(&mut u); + click(&mut u, at); + assert_eq!(u.app.session.tool.tool, ToolId::Eyedropper, "the button picks up the eyedropper"); + let undo = u.app.session.undo_depth().0; + let on_red = u.app.to_screen(30.0, 30.0); + click(&mut u, on_red); + let doc = u.app.session.doc().unwrap(); + let n = doc.node(b).unwrap(); + let hex = |p: Option| p.map(|c| c.to_hex()).unwrap_or_default(); + let picked = if effect { hex(n.effects[0].color(false)) } else { hex(n.strokes[0].solid_color()) }; + assert_eq!(picked, "FF0000", "the picker's paint takes the colour (effect: {effect})"); + assert_eq!(hex(n.fills[0].solid_color()), "00FF00", "the fill is left alone (effect: {effect})"); + assert!(doc.node(red).is_some()); + assert_eq!(u.app.session.tool.tool, ToolId::Move, "picking ends the eyedropper"); + assert_eq!(u.app.ui.picker, Some(target), "the picker stays open on its paint"); + assert_eq!(u.app.session.undo_depth().0, undo + 1, "one undo step"); + } +} + +#[test] +fn closing_the_picker_drops_its_eyedropper() { + // The colour sampled after the picker closed has no paint to go to: it must not wait for the + // next picker, and the eyedropper gives way to the Move tool. + let mut s = Session::new(); + let b = rect(&mut s, 200.0, json!({"fill": "#00FF00", "stroke": "#0000FF"})); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.picker = Some(PickerTarget::Paint { fills: false, index: 0, ids: vec![b] }); + let at = eyedropper_button(&mut u); + click(&mut u, at); + assert_eq!(u.app.session.tool.tool, ToolId::Eyedropper); + u.app.ui.picker = None; + u.app.session.tool.picked = figcraft_color::Color::from_hex("#FF0000"); + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(u.app.session.tool.tool, ToolId::Move); + assert_eq!(u.app.session.tool.picked, None, "a colour sampled for a closed picker is dropped"); +} diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs index 57aae197..fff5536a 100644 --- a/crates/ui-egui/src/colorpicker_selection_tests.rs +++ b/crates/ui-egui/src/colorpicker_selection_tests.rs @@ -52,3 +52,54 @@ fn picker_closes_when_another_layer_is_selected() { u.frame(vec![]); assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker); } + +// Regression test for #237: the "On this page" swatches wrapped onto rows that started at the picker's +// left edge (only the first row was indented) and ran past its right edge. +#[test] +fn wrapped_page_colour_swatches_stay_inset_inside_the_picker() { + use egui::Shape; + let mut s = Session::new(); + let mut first = None; + for i in 0..40 { + let v = s + .execute( + "shape.rectangle", + &json!({"x": i * 30, "y": 0, "width": 20, "height": 20, "props": {"fill": format!("#{:02X}{:02X}80", i * 6, 255 - i * 6)}}), + ) + .unwrap(); + first.get_or_insert(NodeId(v["id"].as_u64().unwrap())); + } + let a = first.unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); + for _ in 0..3 { + u.frame(vec![]); + } + let out = u.frame_output(vec![]); + // The picker is the only floating window open. + let picker = u + .ctx + .memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Middle).find_map(|l| m.area_rect(l.id))) + .expect("the picker is open"); + // A swatch is a 14 pt square with a 1 pt outline. + let swatches: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + Shape::Rect(r) if r.stroke.width == 1.0 && (r.rect.width() - 14.0).abs() < 0.01 && (r.rect.height() - 14.0).abs() < 0.01 => Some(r.rect), + _ => None, + }) + .filter(|r| picker.contains_rect(*r)) + .collect(); + let mut rows: Vec = swatches.iter().map(|r| r.min.y).collect(); + rows.sort_by(f32::total_cmp); + rows.dedup_by(|a, b| (*a - *b).abs() < 1.0); + assert!(rows.len() >= 2, "expected the page colours to wrap onto several rows, got {} swatches in {} row(s)", swatches.len(), rows.len()); + for r in &swatches { + assert!( + r.min.x >= picker.min.x + 16.0 - 0.5 && r.max.x <= picker.max.x - 16.0 + 0.5, + "swatch {r:?} is not inset 16 pt from both sides of the picker {picker:?}" + ); + } +} diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 2b2573c7..6caab6d5 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -88,7 +88,7 @@ fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) /// Local components (and component sets, not their variants), for swap menus: (id, label), page /// by page. Listed once per document revision (a file with thousands of layers isn't walked every /// frame). -fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc> { +pub(crate) fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc> { type Cached = ((u64, u64), std::sync::Arc>); let key = app.session.ds().map(|d| (d.uid, d.revision)).unwrap_or_default(); let id = Id::new("component_panel_local_components"); @@ -521,6 +521,7 @@ fn group_label(g: &str) -> String { "grids" => tr("Layout grids"), "exports" => tr("Export settings"), "interactions" => tr("Interactions"), + "modes" => tr("Variable modes"), "size" => tr("Size"), "resizing" => tr("Resizing"), "position" => tr("Position"), @@ -708,35 +709,9 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps }); } PropDef::InstanceSwap { preferred, .. } => { - let cur = figcraft_engine::params::id_of(&v); - let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("None").into()); - let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance)); - menu_popup(&resp).width(200.0).show(|ui| { - let t = Tokens::get(ui.ctx()); - let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); - let mut pick = |ui: &mut Ui, c: NodeId, name: &str| { - let current = cur == Some(c); - if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { - if !current { - app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}})); - } - ui.close(); - } - }; - let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect(); - if !preferred.is_empty() { - heading(ui, tr("Preferred")); - for (c, name) in &preferred { - pick(ui, *c, name); - } - heading(ui, tr("All components")); - } - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (c, name) in comps { - pick(ui, *c, name); - } - }); - }); + if let Some(c) = swap_dropdown(ui, doc, figcraft_engine::params::id_of(&v), &preferred, comps, w, "").1 { + app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}})); + } } PropDef::Variant { .. } => {} PropDef::Slot { preferred } => { @@ -749,6 +724,54 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps } } +/// The dropdown of an instance-swap property showing component `cur`: its menu lists the +/// property's preferred components first (when it has any), then every component in the file. The +/// dropdown, and the component picked (picking the current one picks nothing). `name` labels it for +/// assistive technology. +pub(crate) fn swap_dropdown( + ui: &mut Ui, + doc: &Document, + cur: Option, + preferred: &[NodeId], + comps: &[(NodeId, String)], + w: f32, + name: &str, +) -> (egui::Response, Option) { + let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("None").into()); + let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance)); + if !name.is_empty() { + crate::a11y::combo(&resp, name, &label, crate::a11y::menu_open(&resp)); + } + let mut picked = None; + menu_popup(&resp).width(200.0).show(|ui| { + let t = Tokens::get(ui.ctx()); + let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); + let mut pick = |ui: &mut Ui, c: NodeId, name: &str| { + let current = cur == Some(c); + if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { + if !current { + picked = Some(c); + } + ui.close(); + } + }; + let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect(); + if !preferred.is_empty() { + heading(ui, tr("Preferred")); + for (c, name) in &preferred { + pick(ui, *c, name); + } + heading(ui, tr("All components")); + } + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (c, name) in comps { + pick(ui, *c, name); + } + }); + }); + (resp, picked) +} + fn prop_label(ui: &mut Ui, name: &str, icon: Icon) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(84.0, 20.0), Sense::hover()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2d35609a..edd9fff7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -336,7 +336,8 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul .ok_or("`events` required: [{move|down|up: [x, y]} | {key, shift?, alt?, cmd?} | {text} | {leave: true}]")?; let pos = |v: &Value| -> Option { let a = v.as_array()?; - Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)) + let p = egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32); + p.is_finite().then_some(p) }; let mut events = Vec::new(); let mut pads = Vec::new(); @@ -633,12 +634,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(v) = p.get("exportPreview").and_then(Value::as_bool) { app.ui.export_thumb = v; } - // Dev Mode's compare view: a ready-for-dev frame or section opens it, null closes it; + // Dev Mode's compare view: a frame or section opens it, null closes it; `devCompareVersion` + // the saved version it compares with (null: when it was marked ready for dev), // `devCompareMode` is sideBySide or overlay, `devCompareSplit` the overlay slider (0–1). if let Some(v) = p.get("devCompare") { - app.ui.dev.compare = figcraft_engine::params::id_of(v).map(crate::dev_compare::CompareView::new); + app.ui.dev.compare = figcraft_engine::params::id_of(v).map(|id| crate::dev_compare::CompareView::new(id, None)); } if let Some(c) = app.ui.dev.compare.as_mut() { + if let Some(v) = p.get("devCompareVersion") { + c.version = v.as_str().map(str::to_string); + } match p.get("devCompareMode").and_then(Value::as_str) { Some("overlay") => c.mode = crate::dev_compare::Mode::Overlay, Some("sideBySide") => c.mode = crate::dev_compare::Mode::SideBySide, diff --git a/crates/ui-egui/src/control_roots_tests.rs b/crates/ui-egui/src/control_roots_tests.rs index 35454bb5..763f00e6 100644 --- a/crates/ui-egui/src/control_roots_tests.rs +++ b/crates/ui-egui/src/control_roots_tests.rs @@ -90,3 +90,13 @@ fn window_captures_are_written_through_the_roots_or_returned() { }); assert!(d.0.join("out/shot.png").is_file() && !d.0.join("outside/shot.png").exists()); } + +#[test] +fn ui_input_rejects_non_finite_coordinates() { + let (mut app, _d) = confined_app("input"); + for ev in [json!({"move": [1e300, 5]}), json!({"down": [5, 1e300]}), json!({"scroll": [1e300, 0]})] { + let r = handle(&mut app, "ui.input", json!({"events": [ev]})); + assert!(r.is_err(), "{ev}: {r:?}"); + } + assert!(app.control_input.iter().all(|e| !matches!(e, egui::Event::PointerMoved(p) if !p.is_finite()))); +} diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs new file mode 100644 index 00000000..5a36f073 --- /dev/null +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -0,0 +1,75 @@ +//! Individual corners in the Design panel, driven headless: the four corner fields sit where +//! their corners are (top left, top right / bottom left, bottom right) and each sets its own. + +use egui::accesskit::Role; +use egui::{Event, Id, Key, Modifiers, Pos2, Rect, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// A rectangle rounded only on its left corners (top left and bottom left 30), selected. +fn scene() -> (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": {"topLeftRadius": 30, "bottomLeftRadius": 30}})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + (Ui::new(s), r) +} + +/// The rectangle's radii as `[top left, top right, bottom right, bottom left]`. +fn radii(u: &Ui, r: NodeId) -> [f64; 4] { + u.app.session.doc().unwrap().node(r).unwrap().corners.radii +} + +/// A full press-and-release at `p`, then settling one frame. +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +/// A press-and-release of Enter, committing the focused number field. +fn enter(u: &mut Ui) { + 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 }, + ]); +} + +/// The middle of the toggle button named `label`, found in the AccessKit tree. +fn named_button(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// Corner field `k` (an index into the radii). +fn field(u: &Ui, k: usize) -> Rect { + u.ctx.read_response(Id::new(("corner", k)).with("field")).expect("corner field shows").rect +} + +/// The fields sit at their corners, and typing in the bottom right one sets only that corner in a +/// single undo step (#1002: the bottom row showed bottom right on the left). +#[test] +fn corner_fields_sit_at_their_corners_and_set_their_own() { + let (mut u, r) = scene(); + let t = named_button(&mut u, "Individual corners"); + click(&mut u, t); + let (tl, tr, br, bl) = (field(&u, 0).center(), field(&u, 1).center(), field(&u, 2).center(), field(&u, 3).center()); + assert!(tl.x < tr.x && (tl.y - tr.y).abs() < 1.0, "top row: top left, then top right"); + assert!(bl.x < br.x && (bl.y - br.y).abs() < 1.0, "bottom row: bottom left, then bottom right"); + assert!(bl.y > tl.y && (bl.x - tl.x).abs() < 1.0, "bottom left sits under top left"); + let history = u.app.session.undo_depth().0; + click(&mut u, br); + u.frame(vec![Event::Text("12".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(radii(&u, r), [30.0, 0.0, 12.0, 30.0]); + assert_eq!(u.app.session.undo_depth().0, history + 1); +} diff --git a/crates/ui-egui/src/dev_compare.rs b/crates/ui-egui/src/dev_compare.rs index 2e21eb54..0fd57aa7 100644 --- a/crates/ui-egui/src/dev_compare.rs +++ b/crates/ui-egui/src/dev_compare.rs @@ -1,12 +1,13 @@ -//! Dev Mode's compare view: a ready-for-dev frame or section as it was when it was marked next to -//! how it is now (side by side, or one over the other with a slider), the changed layers outlined, -//! and the list of what changed (`dev.compare`). Rendered once per document version. The branch -//! review ([`crate::branch_ui`]) shows two versions with the same pieces. +//! Dev Mode's compare view: a frame or section as it was when it was marked ready for dev, or in +//! a saved version picked at the bottom, next to how it is now (side by side, or one over the +//! other with a slider), the changed layers outlined, and the list of what changed +//! (`dev.compare`). Rendered once per document version. The branch review ([`crate::branch_ui`]) +//! shows two versions with the same pieces. use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; -use figcraft_doc::{Document, NodeId}; +use figcraft_doc::{DevStatus, Document, NodeId}; use figcraft_engine::devcompare::{self, ChangeKind, Comparison, LayerChange}; use serde_json::json; @@ -38,25 +39,41 @@ pub struct CompareView { pub mode: Mode, /// Where the overlay slider is (0 = all new, 1 = all old). pub split: f32, + /// The saved version compared with (its id); `None`: the snapshot from when it was marked + /// ready for dev. + pub version: Option, /// The change row under the pointer (its layer is highlighted). hovered: Option, cache: Option, } impl CompareView { - pub fn new(id: NodeId) -> Self { - Self { id, mode: Mode::default(), split: 0.5, hovered: None, cache: None } + pub fn new(id: NodeId, version: Option) -> Self { + Self { id, mode: Mode::default(), split: 0.5, version, hovered: None, cache: None } + } + + /// The view `dev.compare {show}` (or `ui.set`) asks for: `data` holds the layer's `id` and the + /// `version` to compare with. + pub fn requested(data: &serde_json::Value) -> Option { + let id = data.get("id").and_then(figcraft_engine::params::id_of)?; + Some(Self::new(id, data.get("version").and_then(serde_json::Value::as_str).map(str::to_string))) } } -/// What the view shows, built for one version of the document. +/// What the view shows, built for one version of the document and one "before". struct Cached { doc: Arc, + version: Option, + /// The version's document (read once per version picked). + version_doc: Option>, cmp: Comparison, before: Option, after: Option, } +/// A saved version as the picker lists it: (id, name, when). +type VersionRow = (String, String, u64); + /// One version rendered: its texture, the page area it shows and where each listed layer is. pub(crate) struct Shot { tex: egui::TextureHandle, @@ -129,15 +146,33 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let Some(mut view) = app.ui.dev.compare.take() else { return }; let Ok(ds) = app.session.ds() else { return }; let doc = ds.doc.clone(); - if doc.node(view.id).is_none() { + let Some(node) = doc.node(view.id) else { return }; + let ready = node.dev_status == DevStatus::ReadyForDev; + // Newest first; a version deleted meanwhile falls back to the ready-for-dev snapshot (or the + // newest version, for a layer that isn't ready for dev). + let versions: Vec = ds.versions.iter().rev().map(|v| (v.id.clone(), v.name.clone(), v.time)).collect(); + if view.version.as_ref().is_some_and(|v| !versions.iter().any(|(id, ..)| id == v)) || (!ready && view.version.is_none()) { + view.version = if ready { None } else { versions.first().map(|(id, ..)| id.clone()) }; + } + if view.version.is_none() && !ready { return; } - if view.cache.as_ref().is_none_or(|c| !Arc::ptr_eq(&c.doc, &doc)) { - let cmp = devcompare::compare(&doc, view.id); - let before = devcompare::before_doc(&doc, view.id).and_then(|b| Shot::of_changes(ctx, &b, view.id, &cmp.changes, true)); + if view.cache.as_ref().is_none_or(|c| !Arc::ptr_eq(&c.doc, &doc) || c.version != view.version) { + let kept = view.cache.take().filter(|c| c.version == view.version).and_then(|c| c.version_doc); + let version_doc = match &view.version { + Some(v) => kept.or_else(|| ds.versions.iter().find(|x| x.id == *v).and_then(|x| x.document().ok())), + None => None, + }; + let (cmp, before) = match &version_doc { + Some(b) => (devcompare::compare_with(b, &doc, view.id), Some(b.clone())), + None if view.version.is_some() => (devcompare::empty(&doc, view.id), None), + None => (devcompare::compare(&doc, view.id), devcompare::before_doc(&doc, view.id).map(Arc::new)), + }; + let before = before.and_then(|b| Shot::of_changes(ctx, &b, view.id, &cmp.changes, true)); let after = Shot::of_changes(ctx, &doc, view.id, &cmp.changes, false); - view.cache = Some(Cached { doc: doc.clone(), cmp, before, after }); + view.cache = Some(Cached { doc: doc.clone(), version: view.version.clone(), version_doc, cmp, before, after }); } + let version_name = view.version.as_ref().and_then(|v| versions.iter().find(|(id, ..)| id == v)).map(|(_, name, _)| name.clone()); let t = Tokens::get(ctx); let screen = ctx.content_rect(); let size = vec2(1120.0_f32.min(screen.width() - 64.0).max(560.0), 700.0_f32.min(screen.height() - 96.0).max(320.0)); @@ -162,27 +197,16 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ui.horizontal_top(|ui| { let images_w = size.x - LIST_W - 1.0; let (r, _) = ui.allocate_exact_size(vec2(images_w, body_h), Sense::hover()); - if !cache.cmp.snapshot { - let msg = if cache.cmp.changed { - tr( - "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on.", - ) - } else { - tr("Nothing changed since this was marked ready for dev.") - }; - message(ui, &t, r, msg); - } else if let (Some(before), Some(after)) = (&cache.before, &cache.after) { - images(ui, &t, r, [(before, tr("Before")), (after, tr("Now"))], view.mode, &mut view.split, view.hovered); - } + compared(ui, r, cache, version_name.as_deref(), view.mode, &mut view.split, view.hovered); let (line, _) = ui.allocate_exact_size(vec2(1.0, body_h), Sense::hover()); ui.painter().rect_filled(line, CornerRadius::ZERO, t.border); ui.allocate_ui_with_layout(vec2(LIST_W, body_h), top_down, |ui| { - if let Some(a) = list(ui, &t, &cache.cmp, &mut view.hovered) { + if let Some(a) = list(ui, &t, &cache.cmp, view.version.is_some(), &mut view.hovered) { action = Some(a); } }); }); - if let Some(a) = footer(ui, &t, &cache.cmp) { + if let Some(a) = footer(ui, &t, &cache.cmp, ready, &versions, view.version.as_deref()) { action = Some(a); } }); @@ -197,6 +221,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } } Some(Action::Close) => open = false, + Some(Action::Against(v)) => view.version = v, None => {} } if open { @@ -204,10 +229,37 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } } +/// The images area: before and after, or a note saying why there is nothing to show. +/// `version_name`: the saved version compared with (`None`: the ready-for-dev snapshot). +fn compared(ui: &mut Ui, r: Rect, cache: &Cached, version_name: Option<&str>, mode: Mode, split: &mut f32, hovered: Option) { + let t = &Tokens::get(ui.ctx()); + let against = version_name.is_some(); + if !cache.cmp.snapshot { + let msg = if against { + tr("This version can't be read.") + } else if cache.cmp.changed { + tr( + "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on.", + ) + } else { + tr("Nothing changed since this was marked ready for dev.") + }; + message(ui, t, r, msg); + } else if !cache.cmp.changed && against { + message(ui, t, r, tr("Nothing changed since this version.")); + } else if let (Some(before), Some(after)) = (&cache.before, &cache.after) { + images(ui, t, r, [(before, version_name.unwrap_or(tr("Before"))), (after, tr("Now"))], mode, split, hovered); + } else if against { + message(ui, t, r, tr("This version doesn't have it: all of it is new.")); + } +} + enum Action { Select(NodeId), Resolve, Close, + /// Compare with this saved version (`None`: the snapshot from when it was marked ready). + Against(Option), } /// The title row: `title` and `name`, the side by side / overlay switch and the close button. @@ -325,7 +377,7 @@ fn pane(p: &egui::Painter, t: &Tokens, place: Place, shot: &Shot, hovered: Optio } /// The changed layers with their property changes; a click on a layer selects it. -fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, hovered: &mut Option) -> Option { +fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, from_version: bool, hovered: &mut Option) -> Option { let mut out = None; let summary = if !cmp.snapshot { tr("Changes unknown").to_string() @@ -345,7 +397,11 @@ fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, hovered: &mut Option) egui::ScrollArea::vertical().id_salt("compare_list").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); for c in &cmp.changes { - let tip = if c.change == ChangeKind::Removed { tr("Removed since it was marked") } else { tr("Select layer") }; + let tip = match (c.change, from_version) { + (ChangeKind::Removed, false) => tr("Removed since it was marked"), + (ChangeKind::Removed, true) => tr("Removed since this version"), + _ => tr("Select layer"), + }; let resp = change_row(ui, t, c.change, &c.name, c.depth, c.layers, false, tip); if resp.hovered() { now_hovered = Some(c.id); @@ -419,17 +475,42 @@ pub(crate) fn property_lines<'a>(ui: &mut Ui, t: &Tokens, depth: usize, lines: i } } -fn footer(ui: &mut Ui, t: &Tokens, cmp: &Comparison) -> Option { +/// Resolve and Close, and left of them what it compares with: the snapshot from when it was +/// marked ready for dev (`ready`) or one of the saved `versions` (`version`, the one picked). +fn footer(ui: &mut Ui, t: &Tokens, cmp: &Comparison, ready: bool, versions: &[VersionRow], version: Option<&str>) -> Option { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), BAR_H + 1.0), Sense::hover()); ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 1.0)), CornerRadius::ZERO, t.border); let mut out = None; let inner = Rect::from_min_max(pos2(r.min.x + 16.0, r.min.y + 1.0), pos2(r.max.x - 16.0, r.max.y)); + ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { + ui.spacing_mut().item_spacing.x = 8.0; + if !versions.is_empty() { + widgets::label(ui, tr("Compare with"), t.text_secondary); + let picked = version.and_then(|v| versions.iter().find(|(id, ..)| id == v)); + let label = picked.map_or_else(|| tr("When marked ready").to_string(), |(_, name, _)| name.clone()); + let resp = widgets::dropdown(ui, &label, 220.0, Some(Icon::Clock)); + crate::right::menu_popup(&resp).show(|ui| { + if ready && crate::right::menu_check(ui, version.is_none(), tr("When marked ready"), "") { + out = Some(Action::Against(None)); + ui.close(); + } + for (id, name, time) in versions { + if crate::right::menu_check(ui, version == Some(id.as_str()), name, &crate::comments::ago_long(*time)) { + out = Some(Action::Against(Some(id.clone()))); + ui.close(); + } + } + }); + } + }); ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::right_to_left(egui::Align::Center)), |ui| { ui.spacing_mut().item_spacing.x = 8.0; - let resolve = widgets::button(ui, tr("Resolve change"), true, cmp.changed) - .on_hover_text(tr("Back to Ready for dev: the next change compares with how it is now")); - if resolve.clicked() { - out = Some(Action::Resolve); + if version.is_none() { + let resolve = widgets::button(ui, tr("Resolve change"), true, cmp.changed) + .on_hover_text(tr("Back to Ready for dev: the next change compares with how it is now")); + if resolve.clicked() { + out = Some(Action::Resolve); + } } if widgets::button(ui, tr("Close"), false, true).clicked() { out = Some(Action::Close); diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index 0b429820..dcece9b2 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -29,8 +29,12 @@ const CALLOUT_W: f32 = 220.0; const CALLOUT_GAP: f32 = 24.0; /// The status filters of the ready-for-dev list. -const FILTERS: [(Option, &str); 3] = - [(Some(DevStatus::ReadyForDev), tr_noop("Ready for dev")), (Some(DevStatus::Completed), tr_noop("Completed")), (None, tr_noop("Any status"))]; +const FILTERS: [(Option, bool, &str); 4] = [ + (Some(DevStatus::ReadyForDev), false, tr_noop("Ready for dev")), + (Some(DevStatus::ReadyForDev), true, tr_noop("Changed")), + (Some(DevStatus::Completed), false, tr_noop("Completed")), + (None, false, tr_noop("Any status")), +]; /// Which annotations the canvas shows. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -281,13 +285,14 @@ pub fn code(app: &mut FigApp, ui: &Ui, id: NodeId, lang: CodeLang) -> String { } /// The component playground, for a selected instance, component or component set: a preview of the -/// component and a control for each variant, boolean and text property. The values tried here -/// change the preview and the code only (returned when any is set: the Code section shows it), -/// never the document. +/// component and a control for each variant, boolean, text and instance-swap property. The values +/// tried here change the preview and the code only (returned when any is set: the Code section +/// shows it), never the document. pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, lang: CodeLang) -> Option { figcraft_engine::playground::component_of(doc, id)?; let t = Tokens::get(ui.ctx()); let Ok((uid, revision)) = app.session.ds().map(|d| (d.uid, d.revision)) else { return None }; + let comps = crate::component_panel::local_components(app, ui, doc); let play = &mut app.ui.dev.playground; if play.target != Some(id) { *play = Playground { target: Some(id), ..Playground::default() }; @@ -357,7 +362,16 @@ pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: Nod set = Some((name.to_string(), json!(new.chars().take(10_000).collect::()))); } }), - // Instance swaps and slots show as the component has them. + "instanceSwap" => row(ui, 32.0, |ui| { + widgets::label(ui, name, t.text_secondary); + let preferred: Vec = p["preferred"].as_array().into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect(); + let w = ui.available_width().max(60.0); + if let Some(c) = crate::component_panel::swap_dropdown(ui, doc, figcraft_engine::params::id_of(value), &preferred, &comps, w, name).1 + { + set = Some((name.to_string(), json!(c.0))); + } + }), + // Slots show as the component has them. _ => {} } } @@ -500,18 +514,18 @@ pub fn status(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { row(ui, 32.0, |ui| crate::section_panel::status_dropdown(app, ui, doc, sel, w)); if let [id] = sel { let changed = doc.node(*id).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed); - let focused = app.session.ds().ok().and_then(figcraft_engine::DocState::focused) == Some(*id); + let ds = app.session.ds().ok(); + let focused = ds.and_then(figcraft_engine::DocState::focused) == Some(*id); + // Unchanged since it was marked (or not ready for dev): compared with the newest saved version. + let newest = ds.and_then(|d| d.versions.last()).map(|v| v.id.clone()).filter(|_| !changed); row(ui, 32.0, |ui| { ui.spacing_mut().item_spacing.x = 8.0; if !focused && widgets::button(ui, tr("Focus view"), false, true).on_hover_text(tr("Show only this on the canvas")).clicked() { app.run("dev.focus", json!({"id": id})); } - if changed - && widgets::button(ui, tr("Compare changes"), true, true) - .on_hover_text(tr("What changed since it was marked ready for dev")) - .clicked() - { - app.run("dev.compare", json!({"id": id, "show": true})); + let tip = if changed { tr("What changed since it was marked ready for dev") } else { tr("What changed since a saved version") }; + if (changed || newest.is_some()) && widgets::button(ui, tr("Compare changes"), changed, true).on_hover_text(tip).clicked() { + app.run("dev.compare", json!({"id": id, "version": newest, "show": true})); } }); } @@ -819,7 +833,9 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { let filter = app.ui.dev.filter.min(FILTERS.len() - 1); let key = (ds.uid, ds.revision, filter); if app.ui.dev.ready_key != Some(key) { - app.ui.dev.ready = devinspect::dev_items(&ds.doc, FILTERS.get(filter).and_then(|f| f.0), None); + let (status, changed_only) = FILTERS.get(filter).map_or((None, false), |f| (f.0, f.1)); + app.ui.dev.ready = devinspect::dev_items(&ds.doc, status, None); + app.ui.dev.ready.retain(|i| i.changed || !changed_only); app.ui.dev.ready_key = Some(key); } let doc = ds.doc.clone(); @@ -827,9 +843,9 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { widgets::section_header(ui, tr("Ready for dev"), &[], app.ui.dev.ready.is_empty()); let w = ui.available_width() - PAD * 2.0; row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, FILTERS.get(filter).map_or("", |f| tr(f.1)), w, Some(Icon::Code)); + let resp = widgets::dropdown(ui, FILTERS.get(filter).map_or("", |f| tr(f.2)), w, Some(Icon::Code)); menu_popup(&resp).show(|ui| { - for (i, (_, label)) in FILTERS.iter().enumerate() { + for (i, (_, _, label)) in FILTERS.iter().enumerate() { if menu_check(ui, i == filter, tr(label), "") { app.ui.dev.filter = i; ui.close(); @@ -848,15 +864,21 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { if resp.hovered() { ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover); } - let dot = if item.status == DevStatus::ReadyForDev { t.success } else { t.text_tertiary }; + // The status colours the canvas badges use; a changed one also says so after its name. + let n = doc.node(item.id); + let dot = n.and_then(crate::canvas::status_label).map_or(t.text_tertiary, |(_, c)| c); ui.painter().circle_filled(pos2(r.min.x + PAD + 4.0, r.center().y), 4.0, dot); let page = doc.node(item.page).map(|p| p.name.clone()).unwrap_or_default(); let page_w = ui.painter().layout_no_wrap(page.clone(), regular(10.0), t.text_secondary).size().x.min(90.0); let page_r = Rect::from_min_max(pos2(r.max.x - PAD - page_w, r.min.y), pos2(r.max.x - PAD, r.max.y)); ui.painter().with_clip_rect(page_r).text(page_r.right_center(), Align2::RIGHT_CENTER, page, regular(10.0), t.text_secondary); let name_r = Rect::from_min_max(pos2(r.min.x + PAD + 14.0, r.min.y), pos2(page_r.min.x - 8.0, r.max.y)); - let name = widgets::elide(ui, &item.name, name_r.width()); - ui.painter().text(name_r.left_center(), Align2::LEFT_CENTER, name, regular(11.0), t.text); + // Room for the "Changed" badge (about 60 points) after the name. + let name = widgets::elide(ui, &item.name, name_r.width() - if item.changed { 64.0 } else { 0.0 }); + let name_end = ui.painter().text(name_r.left_center(), Align2::LEFT_CENTER, name, regular(11.0), t.text).max.x; + if let Some(n) = n.filter(|_| item.changed) { + crate::canvas::status_badge(&ui.painter().with_clip_rect(name_r), n, pos2(name_end + 6.0, r.center().y)); + } // Hovered: a button to open it in the focus view, over the page name. let focus_r = Rect::from_center_size(pos2(r.max.x - PAD - 10.0, r.center().y), vec2(22.0, 22.0)); let over_focus = ui.rect_contains_pointer(focus_r); diff --git a/crates/ui-egui/src/dev_mode_tests.rs b/crates/ui-egui/src/dev_mode_tests.rs index 941f1b2c..3e2ea9ba 100644 --- a/crates/ui-egui/src/dev_mode_tests.rs +++ b/crates/ui-egui/src/dev_mode_tests.rs @@ -1,8 +1,10 @@ //! Dev Mode in the UI, driven headless (the harness is `present_tests`'): the compare view opens //! from `dev.compare {show}`, switches to the overlay and closes with Escape before the focus view -//! does; the focus view's Escape; the annotation filter. +//! does; the focus view's Escape; the annotation filter; the playground's instance-swap menu. -use egui::{Key, pos2}; +use egui::accesskit::{Node, Role}; +use egui::{Event, Key, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; +use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; @@ -80,3 +82,76 @@ fn annotation_filter_and_categories_panel() { assert_eq!(CategoryFilter::parse(&doc, "none"), Some(CategoryFilter::Uncategorized)); assert_eq!(CategoryFilter::parse(&doc, "nope"), None); } + +/// A "Button" component whose nested "Heart" instance is its instance-swap property "Icon", a +/// second icon "Star", and an instance of the button selected in the Inspect tab. Returns (ui, +/// instance, heart, star). +fn swap_setup() -> (crate::layers_drag_tests::Ui, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let icon = |s: &mut Session, name: &str, x: i32| { + let f = id(&s.execute("frame.create", &json!({"x": x, "y": 0, "width": 24, "height": 24, "name": name})).unwrap()); + id(&s.execute("component.create", &json!({"ids": [f]})).unwrap()) + }; + let (heart, star) = (icon(&mut s, "Heart", 0), icon(&mut s, "Star", 40)); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 100, "width": 120, "height": 40, "name": "Button"})).unwrap()); + let nested = id(&s.execute("instance.create", &json!({"component": heart, "x": 8, "y": 108, "parent": f})).unwrap()); + let button = id(&s.execute("component.create", &json!({"ids": [f]})).unwrap()); + s.execute("component.addProperty", &json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested})).unwrap(); + let inst = id(&s.execute("instance.create", &json!({"component": button, "x": 0, "y": 300})).unwrap()); + s.execute("select.set", &json!({"ids": [inst]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.ctx.enable_accesskit(); + (u, inst, heart, star) +} + +fn tree(u: &mut crate::layers_drag_tests::Ui) -> Vec { + u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect() +} + +fn centre(n: &Node) -> Pos2 { + let b = n.bounds().unwrap(); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click(u: &mut crate::layers_drag_tests::Ui, at: Pos2) { + u.button(at, true); + u.button(at, false); +} + +/// The playground's dropdown for property `name`, scrolled into view. +fn playground_combo(u: &mut crate::layers_drag_tests::Ui, name: &str) -> Option { + for _ in 0..20 { + let combo = tree(u).into_iter().find(|n| n.role() == Role::ComboBox && n.label() == Some(name))?; + if centre(&combo).y < 860.0 { + return Some(combo); + } + let wheel = + Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -100.0), phase: TouchPhase::Move, modifiers: Default::default() }; + u.frame(vec![Event::PointerMoved(pos2(1300.0, 500.0)), wheel]); + for _ in 0..30 { + u.frame(vec![]); + } + } + None +} + +#[test] +fn the_playground_swaps_an_instance_in_the_preview_only() { + let (mut u, inst, heart, star) = swap_setup(); + let (doc, undo) = (u.app.session.ds().unwrap().doc.clone(), u.app.session.undo_depth()); + let combo = playground_combo(&mut u, "Icon").expect("the playground has a dropdown for the instance-swap property"); + assert_eq!(combo.value(), Some("Heart")); + // Open it and pick Star: the item that wasn't there before the menu opened. + let before: Vec = tree(&mut u).iter().filter(|n| n.label() == Some("Star")).map(centre).collect(); + click(&mut u, centre(&combo)); + let item = tree(&mut u).into_iter().find(|n| n.label() == Some("Star") && !before.contains(¢re(n))).expect("Star in the menu"); + click(&mut u, centre(&item)); + let combo = playground_combo(&mut u, "Icon").unwrap(); + assert_eq!(combo.value(), Some("Star"), "the playground shows the swapped icon"); + // The file isn't touched: the instance still has the heart, nothing to undo. + assert!(std::sync::Arc::ptr_eq(&doc, &u.app.session.ds().unwrap().doc)); + assert_eq!(u.app.session.undo_depth(), undo); + let props = u.app.session.execute("component.properties", &json!({"id": inst})).unwrap(); + assert_eq!(props["properties"][0]["value"], json!(heart.0), "{star:?}"); +} diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs new file mode 100644 index 00000000..7008e9e5 --- /dev/null +++ b/crates/ui-egui/src/devmode_tail_tests.rs @@ -0,0 +1,110 @@ +//! Dev Mode in the UI, the tail: the compare view compares with a saved version picked at its +//! bottom, and falls back when that version goes away. + +use figcraft_engine::Session; +use serde_json::json; + +use crate::dev_compare::CompareView; +use crate::present_tests::{Ui, id}; + +#[test] +fn compare_view_compares_with_a_saved_version() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "Login"})).unwrap()); + let r = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 300, "width": 120, "height": 40, "parent": f})).unwrap()); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + // Neither ready for dev nor any version: nothing to compare with, the view doesn't stay. + u.app.ui.dev.compare = Some(CompareView::new(f, None)); + u.frames(2, Vec::new); + assert!(u.app.ui.dev.compare.is_none()); + let v = u.run("file.saveVersion", json!({"name": "Kickoff"})); + let v = v["id"].as_str().unwrap().to_string(); + u.run("node.set", json!({"id": r, "props": {"width": 160}})); + u.run("dev.compare", json!({"id": f, "version": v, "show": true})); + u.frames(2, Vec::new); + assert_eq!(u.app.inspect()["devCompare"]["version"], json!(v)); + // Opened without one, a layer that isn't ready compares with the newest version. + u.app.ui.dev.compare = Some(CompareView::new(f, None)); + u.frames(2, Vec::new); + assert_eq!(u.app.inspect()["devCompare"]["version"], json!(v)); + // Ready for dev: the snapshot from when it was marked is there too, and a deleted version + // falls back to it. + u.run("node.set", json!({"id": f, "props": {"devStatus": "readyForDev"}})); + u.run("node.set", json!({"id": r, "props": {"height": 48}})); + u.run("version.delete", json!({"id": v})); + u.frames(2, Vec::new); + assert_eq!(u.app.inspect()["devCompare"]["version"], json!(null)); + assert!(u.app.ui.dev.compare.is_some()); +} + +#[test] +fn dev_mode_keys_refuse_design_edits_and_say_why() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "Login"})).unwrap()); + let r = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 120, "height": 40, "parent": f})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + let at = egui::pos2(700.0, 450.0); + let x = |u: &Ui| u.app.session.doc().unwrap().node(r).map(|n| n.transform.as_coeffs()[4]); + for key in [egui::Key::Delete, egui::Key::ArrowRight, egui::Key::Num5, egui::Key::R] { + u.key(at, key); + } + assert!(u.app.session.doc().unwrap().contains(r), "not deleted"); + assert_eq!(x(&u), Some(20.0), "not nudged"); + assert_eq!(u.app.session.doc().unwrap().node(r).map(|n| n.opacity), Some(1.0), "opacity kept"); + assert_eq!(u.app.session.tool.tool, figcraft_engine::tools::ToolId::Move, "no drawing tool"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); + // In the design the same keys edit. + u.app.ui.right_tab = crate::RightTab::Design; + u.frame(vec![]); + u.key(at, egui::Key::ArrowRight); + assert_eq!(x(&u), Some(21.0)); +} + +#[test] +fn read_only_plugins_run_from_the_inspect_tab_show_their_panel() { + use base64::Engine as _; + let wasm = base64::engine::general_purpose::STANDARD.encode(crate::plugin_panel_tests::panel_module()); + let manifest = json!({"id": "org.test.inspect", "name": "Inspect panel", "main": "p.wasm", "api": 2, "permissions": {"access": "read"}}); + let mut s = Session::new(); + s.execute("plugin.import", &json!({"manifest": manifest, "wasm": wasm})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + // What the Inspect tab's Plugins row runs. + crate::plugins_ui::start(&mut u.app, "org.test.inspect", "run"); + u.frame(vec![]); + assert_eq!(u.app.session.plugins.panels.len(), 1, "its panel shows"); + // Its callbacks only read: the button that would draw is refused and the design stays. + let layers = |u: &Ui| u.app.session.doc().unwrap().ids().count(); + let before = layers(&u); + assert!(u.app.session.execute("plugin.event", &json!({"id": "org.test.inspect", "control": "go"})).is_err()); + assert_eq!(layers(&u), before); +} + +/// A clipboard holding only text. +struct TextClip(String); + +impl crate::Services for TextClip { + fn clipboard_get_text(&mut self) -> Option { + Some(self.0.clone()) + } +} + +#[test] +fn dev_mode_pastes_only_a_comments_link() { + let mut s = Session::new(); + let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "Check the spacing"})).unwrap()["id"].as_u64().unwrap(); + let layers = |app: &crate::FigApp| app.session.doc().unwrap().node(app.session.page().unwrap()).unwrap().children.len(); + let mut app = crate::FigApp::new(s, Box::new(TextClip("Pasted words".into()))); + app.ui.right_tab = crate::RightTab::Inspect; + crate::menus::paste(&mut app, None, None); + assert_eq!(layers(&app), 0, "text doesn't paste into the design in Dev Mode"); + app.services = Box::new(TextClip(format!("figcraft://comment/{c}"))); + crate::menus::paste(&mut app, None, None); + assert_eq!(app.ui.comments.open, Some(c), "the link opens its thread"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bf9f1b29..85d8ca15 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -498,12 +498,42 @@ fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: f64) { let resp = crate::widgets::dropdown(ui, &shown, 120.0, None); crate::a11y::combo(&resp, tr("Interface size"), &shown, crate::a11y::menu_open(&resp)); crate::right::menu_popup(&resp).show(|ui| { + let focused = ui.memory(|memory| memory.focused()); + let step = ui.input(|input| { + if input.key_pressed(egui::Key::ArrowDown) || (input.key_pressed(egui::Key::Tab) && !input.modifiers.shift) { + 1 + } else if input.key_pressed(egui::Key::ArrowUp) || (input.key_pressed(egui::Key::Tab) && input.modifiers.shift) { + -1 + } else { + 0 + } + }); + let mut rows = Vec::with_capacity(crate::ui_scale::SIZES.len()); for size in crate::ui_scale::SIZES { - if crate::right::menu_check(ui, (chosen - size).abs() < 1e-6, &percent(size), "") { + let label = percent(size); + let selected = (chosen - size).abs() < 1e-6; + let row = crate::right::menu_row(ui, &format!("{}{label}", if selected { "✓ " } else { " " }), "", 0.0, |_, _, _| {}); + crate::a11y::menu_item(&row, &label, "", Some(selected)); + if row.has_focus() { + ui.memory_mut(|memory| memory.set_focus_lock_filter(row.id, egui::EventFilter { tab: true, ..Default::default() })); + } + rows.push(row.id); + if row.clicked() { app.run("pref.uiScale", json!({"scale": size})); ui.close(); } } + // Spatial focus can jump to a control behind the popup when the menu overlaps the + // Preferences window. Once an item is focused, walk the menu's own rows instead. + if step != 0 + && let Some(index) = rows.iter().position(|id| Some(*id) == focused) + { + let next = index.saturating_add_signed(step).min(rows.len() - 1); + ui.memory_mut(|memory| { + memory.request_focus(rows[next]); + memory.move_focus(egui::FocusDirection::None); + }); + } }); }); crate::widgets::label( diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index fea0be8e..dd63988a 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -370,6 +370,58 @@ fn arithmetic_commits_tab_moves_on_and_escape_restores() { assert_eq!(geom(&u, a).0, 78.0); } +/// Answers a Save as dialog with `path`, once, and counts the dialogs asked for. +struct SaveAsPicker { + path: Option, + asked: std::rc::Rc>, +} + +impl crate::Services for SaveAsPicker { + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> crate::DialogFuture { + self.asked.set(self.asked.get() + 1); + Box::pin(std::future::ready(self.path.take())) + } +} + +/// The x of layer `a` in the file at `path`, opened again. +fn saved_x(path: &std::path::Path, a: NodeId) -> f64 { + let mut back = Session::new(); + back.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + back.doc().unwrap().world_bounds(a).unwrap().x0 +} + +#[test] +fn the_save_shortcut_saves_from_a_field_with_what_was_typed() { + // Cmd/Ctrl+S with the keyboard in a panel field saved nothing (#1157). It commits the field + // first (as clicking away does), then saves; the field lets go of the keyboard. + // Shift+Cmd/Ctrl+S does the same through Save as. + let cmd = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + let (mut u, a, _) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-save-from-field-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, copy) = (dir.join("doc.figcraft"), dir.join("copy.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); + for (shortcut, mods, x, file) in [("Cmd/Ctrl+S", cmd, 77.0, &path), ("Shift+Cmd/Ctrl+S", cmd.plus(Modifiers::SHIFT), 66.0, ©)] { + let (p, _) = field(&mut u, "X"); + click(&mut u, p); + assert!(crate::a11y::field_focused(&u.ctx), "{shortcut}: the field has the keyboard"); + u.frame(vec![Event::Text(x.to_string())]); + key(&mut u, Key::S, mods); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(geom(&u, a).0, x, "{shortcut}: what was typed is committed"); + assert!(!crate::a11y::field_focused(&u.ctx), "{shortcut}: the field let go"); + assert!(!u.app.session.ds().unwrap().is_dirty(), "{shortcut}: saved"); + assert_eq!(saved_x(file, a), x, "{shortcut}: the file on disk has the typed value"); + } + assert_eq!(asked.get(), 1, "only Save as asked where"); + assert_eq!(saved_x(&path, a), 77.0, "Save as left the first file alone"); + std::fs::remove_dir_all(&dir).ok(); +} + #[test] fn mixed_values_take_edits_relative_to_each_layer() { let (mut u, a, b) = setup(); diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index b532d9a7..326a3b14 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -321,13 +321,14 @@ fn action_card(ui: &mut Ui, t: &Tokens, icon: Icon, title: &str, sub: &str) -> b fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { heading(ui, t, tr("Recents")); - let (mut new_file, mut open) = (false, false); - ui.horizontal(|ui| { - new_file = action_card(ui, t, Icon::Plus, tr("New design file"), tr("Start from a blank canvas")); + let (mut new_file, mut new_board, mut open) = (false, false, false); + ui.horizontal_wrapped(|ui| { + new_file = action_card(ui, t, Icon::PenNib, tr("New design file"), tr("Start from a blank canvas")); + new_board = action_card(ui, t, Icon::Board, tr("New board"), tr("Sticky notes, shapes and connectors")); open = action_card(ui, t, Icon::Page, tr("Open file"), tr("Open .figcraft, .svg or images")); }); - if new_file { - app.run("file.new", json!({})); + if new_file || new_board { + app.run(if new_board { "file.newBoard" } else { "file.new" }, json!({})); app.ui.home = false; } if open { @@ -376,8 +377,14 @@ fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize { let mut switch = None; for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| hit(d)) { let detail = d.path.as_deref().unwrap_or_default(); - let row = - FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary }; + let row = FileRow { + mark: Some((icons::file_mark(d.doc.kind), ACCENT)), + name: &d.doc.name, + ink: t.text, + detail, + detail_size: 11.0, + detail_ink: t.text_secondary, + }; if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) { switch = Some(i); } diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index ca50d78f..2d39c6fe 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -252,6 +252,8 @@ pub struct Shown { pub name: String, /// `None` until the file has been looked at. pub stat: Option, + /// A design file or a board (design until its picture has been read). + pub kind: figcraft_doc::FileKind, } impl Shown { @@ -259,7 +261,7 @@ impl Shown { // Shown without its extension, like a document's name; the full path is the tooltip. let p = Path::new(path); let name = p.file_stem().or_else(|| p.file_name()).map_or_else(|| path.to_string(), |f| f.to_string_lossy().into_owned()); - Shown { path: path.to_string(), name, stat } + Shown { path: path.to_string(), name, stat, kind: figcraft_doc::FileKind::Design } } fn date(&self, key: SortKey) -> Option { @@ -311,6 +313,7 @@ struct Ready { modified: Option, page: Option, texture: Option, + kind: figcraft_doc::FileKind, /// When it came in (for its fade). arrived: f64, } @@ -479,7 +482,7 @@ impl Recents { let page = preview.page.map(|[r, g, b, a]| crate::theme::c32(figcraft_color::Color { r, g, b, a })); // A changed file's new picture shows at once; a first one fades in. let arrived = if k.shown.is_some() { f64::NEG_INFINITY } else { time }; - k.shown = Some(Ready { modified, page, texture, arrived }); + k.shown = Some(Ready { modified, page, texture, kind: preview.kind, arrived }); k.wanted = None; } } @@ -519,7 +522,13 @@ impl Recents { /// The files as listed, with what is known about them. fn shown(&self, paths: &[String]) -> Vec { - paths.iter().map(|p| Shown::new(p, self.files.get(p).and_then(|k| k.stat))).collect() + paths + .iter() + .map(|p| { + let k = self.files.get(p); + Shown { kind: k.and_then(|k| k.shown.as_ref()).map_or_else(Default::default, |r| r.kind), ..Shown::new(p, k.and_then(|k| k.stat)) } + }) + .collect() } /// For `ui.inspect`: each file listed, whether it is there and whether its picture is in. @@ -751,13 +760,13 @@ fn arrows(ui: &Ui, recents: &mut Recents, files: &[Shown], across: usize, down: } } -/// The square file-type mark before a name. -fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { +/// The square file-type mark before a name (a design file's pen nib, a board's note). +fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, file: &Shown) { let r = Rect::from_center_size(center, vec2(16.0, 16.0)); - match FileKind::of(Path::new(path)) { + match FileKind::of(Path::new(&file.path)) { FileKind::FigCraft | FileKind::Fig => { ui.painter().rect_filled(r, CornerRadius::same(4), t.accent); - icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, t.accent_on); + icons::paint(ui.painter(), r.shrink(3.0), icons::file_mark(file.kind), t.accent_on); } kind => { ui.painter().rect_filled(r, CornerRadius::same(4), t.field); @@ -867,7 +876,7 @@ fn card( } p.line_segment([pos2(rect.min.x, area.max.y + 0.5), pos2(rect.max.x, area.max.y + 0.5)], Stroke::new(1.0, t.border)); let y = area.max.y + 1.0 + FOOTER_H / 2.0; - file_icon(ui, t, pos2(rect.min.x + 24.0, y), &file.path); + file_icon(ui, t, pos2(rect.min.x + 24.0, y), file); let (x, w) = (rect.min.x + 44.0, rect.width() - 56.0); let name = widgets::elide_font(ui, &file.name, w, semibold(12.5)); p.text(pos2(x, y - 8.0), Align2::LEFT_CENTER, name, semibold(12.5), t.text); @@ -935,6 +944,8 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6 if !ui.is_rect_visible(r) { continue; } + // The row's mark says what kind of file it is: read it like a card's picture. + recents.picture(&file.path, true, ui.input(|i| i.time)); let selected = recents.selected.as_deref() == Some(file.path.as_str()); let p = ui.painter(); if selected { @@ -945,7 +956,7 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6 if resp.has_focus() && !selected { p.rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, t.focus), StrokeKind::Inside); } - file_icon(ui, t, pos2(r.min.x + 20.0, r.center().y), &file.path); + file_icon(ui, t, pos2(r.min.x + 20.0, r.center().y), file); let name_w = date_x(1) - 52.0; p.text( pos2(r.min.x + 40.0, r.center().y), diff --git a/crates/ui-egui/src/home_recents_tests.rs b/crates/ui-egui/src/home_recents_tests.rs index e49866ab..d01e5222 100644 --- a/crates/ui-egui/src/home_recents_tests.rs +++ b/crates/ui-egui/src/home_recents_tests.rs @@ -74,7 +74,7 @@ fn columns_follow_the_width() { fn shown(path: &str, modified: Option, created: Option) -> Shown { let stat = Some(Stat { exists: modified.is_some(), modified, created }); - Shown { path: path.into(), name: Path::new(path).file_stem().unwrap().to_string_lossy().into(), stat } + Shown { path: path.into(), name: Path::new(path).file_stem().unwrap().to_string_lossy().into(), stat, kind: Default::default() } } #[test] diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 3a99e814..1a546845 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -1,13 +1,11 @@ //! The catalog checks: every key the code looks up is in the English catalog, no catalog has keys -//! the code doesn't use, placeholders match English, and complete languages translate everything. +//! the code doesn't use, and placeholders match English. A missing translation reads English and +//! fails nothing: [`missing_translations_report`] lists them (`cargo xtask i18n-report`). use std::collections::BTreeSet; use super::*; -/// Languages that must translate every UI string and command label. -const COMPLETE: &[&str] = &["es"]; - /// Keys looked up from data rather than written as `tr("…")` in the code: the main menu's /// submenu names (the engine's menu catalog) and labels of engine and document enums. fn dynamic_keys() -> BTreeSet { @@ -132,6 +130,9 @@ fn dynamic_labels() -> Vec<&'static str> { use figcraft_engine::keymap::{GESTURES, Group}; let mut v: Vec<&'static str> = figcraft_engine::ToolId::ALL.iter().map(|t| t.label()).collect(); v.extend(figcraft_engine::vectoredit::VTool::ALL.iter().map(|t| t.label())); + // Board shapes and stamps (the board tools' flyouts and the contextual bar). + v.extend(figcraft_doc::BoardShape::ALL.iter().map(|b| b.label())); + v.extend(figcraft_doc::Stamp::ALL.iter().map(|b| b.label())); v.extend(Group::ALL.iter().map(|g| g.label())); v.extend(GESTURES.iter().flat_map(|(_, label, mac, other)| [*label, *mac, *other])); v.extend(DATA_LABELS); @@ -274,14 +275,19 @@ fn english_text_is_its_key_except_plurals() { assert!(english().commands.is_empty() && english().menu.is_empty(), "English command labels come from the engine"); } -#[test] -fn catalogs_have_known_keys_and_matching_placeholders() { - let en = english(); - let ids: BTreeSet<&str> = figcraft_engine::all_commands() +/// The ids `[commands]` entries translate: every command and every main-menu item. +fn command_ids() -> BTreeSet<&'static str> { + figcraft_engine::all_commands() .iter() .map(|c| c.id) .chain(figcraft_engine::catalog::MENU.iter().map(|m| m.id).filter(|id| !id.is_empty())) - .collect(); + .collect() +} + +#[test] +fn catalogs_have_known_keys_and_matching_placeholders() { + let en = english(); + let ids = command_ids(); for lang in LANGS { assert!(!lang.name.is_empty(), "{}: [meta] name is missing", lang.code); for (k, t) in lang.ui { @@ -302,13 +308,29 @@ fn catalogs_have_known_keys_and_matching_placeholders() { for (id, _) in lang.menu { assert!(menu_worded_differently().contains(id), "{}: [menu] {id:?} reads like the command; [commands] covers it", lang.code); } - if COMPLETE.contains(&lang.code) { - let missing: Vec<_> = en.ui.iter().filter(|(k, _)| find(lang.ui, k).is_none()).map(|(k, _)| k).collect(); - assert!(missing.is_empty(), "{}: untranslated: {missing:?}", lang.code); - let missing: Vec<_> = ids.iter().filter(|id| find(lang.commands, id).is_none()).collect(); - assert!(missing.is_empty(), "{}: untranslated commands: {missing:?}", lang.code); - let missing: Vec<_> = menu_worded_differently().into_iter().filter(|id| find(lang.menu, id).is_none()).collect(); - assert!(missing.is_empty(), "{}: menu items worded unlike their command, missing from [menu]: {missing:?}", lang.code); + } +} + +/// What each language lacks: UI strings, command labels and menu wordings that read as in English +/// because neither its catalog nor the one it overlays has them. It prints them and always passes. +#[test] +fn missing_translations_report() { + let ids = command_ids(); + let worded = menu_worded_differently(); + for (i, lang) in LANGS.iter().enumerate().filter(|(_, l)| l.code != "en") { + let own = || chain(i).filter(|l| l.code != "en"); + let ui: Vec<_> = english().ui.iter().map(|(k, _)| *k).filter(|k| own().all(|l| find(l.ui, k).is_none())).collect(); + let commands: Vec<_> = ids.iter().filter(|id| own().all(|l| find(l.commands, id).is_none())).collect(); + let menu: Vec<_> = worded.iter().filter(|id| own().all(|l| find(l.menu, id).is_none())).collect(); + println!("{}: {} UI strings, {} command labels, {} menu wordings missing", lang.code, ui.len(), commands.len(), menu.len()); + for k in ui { + println!(" {k:?}"); + } + for id in commands { + println!(" [commands] {id}"); + } + for id in menu { + println!(" [menu] {id}"); } } } @@ -344,8 +366,6 @@ fn system_languages_pick_the_first_one_with_a_catalog() { assert_eq!(code("system", Some("cs_CZ.UTF-8")), "cs"); assert_eq!(code("system", Some("cs-CZ,en-US")), "cs"); assert_eq!(code("cs", None), "cs"); - assert_eq!(LANGS[resolve("cs", None)].name, "Čeština"); - assert!(LANGS[resolve("cs", None)].draft); assert_eq!(code("system", Some("pt-BR")), "pt-BR"); assert_eq!(code("system", Some("tr_TR.UTF-8")), "tr"); assert_eq!(code("system", Some("id_ID.UTF-8")), "id"); @@ -380,8 +400,6 @@ fn swedish_is_picked_for_swedish_locales() { assert_eq!(code("system", Some(tag)), "sv", "{tag}"); } let sv = LANGS.iter().position(|l| l.code == "sv").unwrap(); - assert_eq!(LANGS[sv].name, "Svenska"); - assert!(!LANGS[sv].draft); assert!(!plural_one(sv, 0) && plural_one(sv, 1) && !plural_one(sv, 2)); } @@ -399,10 +417,11 @@ fn latin_american_spanish_overlays_spanish() { assert_eq!(LANGS[la].parent, es); assert!(es < la, "the picker lists Español first"); // Its own wording, else Spanish's, else English's. - assert!(matches!(lookup(la, "Add fill"), Some(Text::Plain("Agregar relleno")))); - assert!(matches!(lookup(la, "Fill"), Some(Text::Plain("Relleno")))); + for (k, _) in LANGS[es].ui.iter().filter(|(k, _)| find(LANGS[la].ui, k).is_none()) { + assert!(lookup(la, k).is_some_and(|t| std::ptr::eq(t, find(LANGS[es].ui, k).unwrap())), "{k:?}"); + } let undo = |i: usize| chain(i).find_map(|l| find(l.commands, "edit.undo")).map(forms); - assert!(undo(es).is_some() && undo(la) == undo(es), "command labels fall back too"); + assert_eq!(undo(la), undo(es), "command labels fall back too"); // An overlay lists only what differs from the catalog under it. for lang in LANGS.iter().filter(|l| l.parent != 0) { let under = &LANGS[lang.parent]; @@ -443,19 +462,11 @@ fn japanese_korean_and_chinese_have_no_plural_forms() { } #[test] -fn georgian_is_a_draft_with_count_neutral_plurals() { +fn georgian_is_picked_for_georgian_locales() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; for tag in ["ka", "ka-GE", "ka_GE.UTF-8"] { assert_eq!(code("system", Some(tag)), "ka", "{tag}"); } - let ka = LANGS.iter().position(|l| l.code == "ka").unwrap(); - assert!(LANGS[ka].draft && LANGS[ka].name == "ქართული"); - // A noun stays singular after a numeral: both forms read the same where English counts in both. - for (k, t) in LANGS[ka].ui { - if let (Text::Plural { one: Some(one), other, .. }, Some(Text::Plural { one: Some(en_one), .. })) = (t, find(english().ui, k)) { - assert!(!en_one.contains("{n}") || one == other, "ka: {k:?}"); - } - } } /// Release builds embed craft-fonts' Noto Sans Georgian (`theme::install_fonts`' built-in faces), @@ -489,7 +500,6 @@ fn chinese_reads_simplified_and_traditional_has_no_catalog() { assert_eq!(code(tag, None), "en", "{tag}"); } assert_eq!(code("system", Some("zh-TW,ja-JP")), "ja"); - assert_eq!(LANGS[resolve("zh-Hans", None)].name, "简体中文"); } #[test] @@ -503,28 +513,18 @@ fn the_key_scanner_reads_rust_literals() { #[test] fn czech_integer_plurals() { - let cs = LANGS.iter().position(|l| l.code == "cs").unwrap(); assert_eq!(plural("cs", 1), Plural::One); - assert_eq!(fill(counted(cs, "{n} layers", 1), &[("n", &1)]), "1 vrstva"); for n in [2, 3, 4] { assert_eq!(plural("cs", n), Plural::Few, "{n}"); - assert_eq!(fill(counted(cs, "{n} layers", n), &[("n", &n)]), format!("{n} vrstvy")); } for n in [0, 5, 11, 12, 21, 22, 100, 1_000_000] { assert_eq!(plural("cs", n), Plural::Other, "{n}"); - assert_eq!(fill(counted(cs, "{n} layers", n), &[("n", &n)]), format!("{n} vrstev")); - } - for (key, text) in LANGS[cs].ui { - if let Text::Plural { one, few, .. } = text { - assert!(one.is_some() && few.is_some(), "{key}: missing Czech plural forms"); - } } } #[test] fn russian_language_tags_and_integer_plurals() { let ru = LANGS.iter().position(|l| l.code == "ru").unwrap(); - assert_eq!(LANGS[ru].name, "Русский"); for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "RU", "ru-BY", "ru-Cyrl-RU"] { assert_eq!(resolve(tag, None), ru, "{tag}"); assert_eq!(resolve("system", Some(tag)), ru, "{tag}"); @@ -532,49 +532,34 @@ fn russian_language_tags_and_integer_plurals() { assert_eq!(resolve("system", Some("hi,ru-RU,en-US")), ru); for n in [1, 21, 31, 101, 1_001] { assert_eq!(plural("ru", n), Plural::One, "{n}"); - assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слой")); } for n in [2, 3, 4, 22, 23, 24, 102, 104, 1_002] { assert_eq!(plural("ru", n), Plural::Few, "{n}"); - assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слоя")); } for n in [0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 111, 112, 114, 1_000_000] { assert_eq!(plural("ru", n), Plural::Many, "{n}"); - assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слоёв")); - } - for (key, text) in LANGS[ru].ui { - if let Text::Plural { one, few, many, .. } = text { - assert!(one.is_some() && few.is_some() && many.is_some(), "{key}: missing Russian plural forms"); - } } } #[test] fn polish_integer_plurals() { - let pl = LANGS.iter().position(|l| l.code == "pl").unwrap(); - assert_eq!(LANGS[pl].name, "Polski"); assert_eq!(plural("pl", 1), Plural::One); - assert_eq!(fill(counted(pl, "{n} layers", 1), &[("n", &1)]), "1 warstwa"); for n in [2, 3, 4, 22, 23, 24, 102, 104, 1_002] { assert_eq!(plural("pl", n), Plural::Few, "{n}"); - assert_eq!(fill(counted(pl, "{n} layers", n), &[("n", &n)]), format!("{n} warstwy")); } for n in [0, 5, 10, 11, 12, 13, 14, 19, 20, 21, 25, 31, 100, 101, 111, 112, 114, 1_000_000] { assert_eq!(plural("pl", n), Plural::Many, "{n}"); - assert_eq!(fill(counted(pl, "{n} layers", n), &[("n", &n)]), format!("{n} warstw")); - } - for (key, text) in LANGS[pl].ui { - if let Text::Plural { one, few, many, .. } = text { - assert!(one.is_some() && few.is_some() && many.is_some(), "{key}: missing Polish plural forms"); - } } } #[test] fn plural_fallback_uses_the_language_of_its_catalog() { + // A plural es-419 doesn't overlay reads Spanish's entry under Spanish's rule. + let es = LANGS.iter().position(|l| l.code == "es").unwrap(); let la = LANGS.iter().position(|l| l.code == "es-419").unwrap(); - assert_eq!(counted(la, "{n} layers", 1), "{n} capa"); - assert_eq!(counted(la, "{n} layers", 21), "{n} capas"); + for n in [1, 21] { + assert_eq!(counted(la, "{n} layers", n), counted(es, "{n} layers", n), "{n}"); + } // Unsupported optional categories use other; existing one/other languages are unchanged. for code in ["en", "es", "de"] { for n in [0, 2, 11, 21, 1_000_000] { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 5eb7d209..8275df5e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -83,6 +83,11 @@ icons! { FlipH, FlipV, CornerRadius, + /// Individual corners: the rounded corner each field sets. + CornerTopLeft, + CornerTopRight, + CornerBottomRight, + CornerBottomLeft, Angle, IndependentCorners, /// Individual padding: a frame with one inward tick per side, each set alone. @@ -193,6 +198,17 @@ icons! { LiveShared, /// A file of yours on a server that only you get into: a point inside a closed ring. LivePrivate, + /// A design file: a pen nib leaning to the left, its grip filled. + PenNib, + /// A board file (and the sticky note tool): a note with its bottom corner folded up. + Board, + /// Board tools: a chisel-tipped marker; a broad highlighter over its band; a connector + /// between two boxes; shapes (a square behind a circle); a stamp (a badge with a check). + Marker, + Highlighter, + Connector, + Shapes, + Stamp, } /// Points along a circular arc from angle `a0` to `a1` (radians, y down: increasing is clockwise). @@ -708,7 +724,10 @@ fn draw(p: &P, icon: Icon) { p.poly(&[(3.5, 5.5), (12.5, 5.5), (12.5, 1.5)], true); p.fill(&[(3.0, 9.0), (13.0, 9.0), (13.0, 14.0)]); } - CornerRadius => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0), + CornerRadius | CornerTopLeft => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0), + CornerTopRight => p.corner(13.5, 2.5, -1.0, 1.0, 11.5, 6.0), + CornerBottomRight => p.corner(13.5, 13.5, -1.0, -1.0, 11.5, 6.0), + CornerBottomLeft => p.corner(2.5, 13.5, 1.0, -1.0, 11.5, 6.0), Angle => { // Two rays from a vertex, the arc between them marking the angle (all unhinted, so // the arc meets the rays at every size). @@ -1118,6 +1137,49 @@ fn draw(p: &P, icon: Icon) { p.line(8.0, 6.0, 8.0, 9.5); p.dot(8.0, 11.5, 0.9); } + PenNib => { + // Drawn pointing down, then leant 35° to the left about the middle. + let (sin, cos) = (0.61f32, 0.79f32); + let at = |x: f32, y: f32| (8.0 + x * cos - y * sin, 8.0 + x * sin + y * cos); + let nib: Vec<(f32, f32)> = [(0.0, 6.5), (3.4, 1.0), (2.4, -2.5), (-2.4, -2.5), (-3.4, 1.0)].iter().map(|&(x, y)| at(x, y)).collect(); + p.path(&nib, true); + let (a, b) = (at(0.0, 6.5), at(0.0, 2.0)); + p.path(&[a, b], false); + let (dx, dy) = at(0.0, 0.6); + p.dot(dx, dy, 1.0); + let grip: Vec<(f32, f32)> = [(-2.4, -3.4), (2.4, -3.4), (2.4, -5.6), (-2.4, -5.6)].iter().map(|&(x, y)| at(x, y)).collect(); + p.fill(&grip); + } + Board => { + p.poly(&[(2.5, 2.5), (13.5, 2.5), (13.5, 9.5), (9.5, 13.5), (2.5, 13.5)], true); + p.fill(&[(9.5, 13.5), (9.5, 9.5), (13.5, 9.5)]); + p.line(5.0, 6.5, 11.0, 6.5); + p.line(5.0, 9.5, 8.0, 9.5); + } + Marker => { + p.poly(&[(3.0, 13.0), (3.0, 10.5), (10.0, 3.5), (12.5, 6.0), (5.5, 13.0)], true); + p.line(8.5, 5.0, 11.0, 7.5); + p.fill(&[(2.0, 14.0), (2.0, 11.5), (4.5, 14.0)]); + } + Highlighter => { + p.poly(&[(4.0, 10.0), (10.5, 3.5), (13.5, 6.5), (7.0, 13.0)], true); + p.poly(&[(4.0, 10.0), (2.5, 12.5), (4.5, 12.5), (7.0, 13.0)], false); + p.fill_rect(1.0, 14.0, 15.0, 15.5, 0.0); + } + Connector => { + p.rect(1.5, 1.5, 6.5, 6.5, 1.0); + p.rect(9.5, 9.5, 14.5, 14.5, 1.0); + p.poly(&[(6.5, 4.0), (12.0, 4.0), (12.0, 9.0)], false); + p.head((12.0, 9.0), FRAC_PI_2, 2.4); + } + Shapes => { + p.rect(1.5, 5.5, 9.5, 13.5, 1.0); + p.circle(10.5, 5.5, 4.0); + } + Stamp => { + p.circle(8.0, 8.0, 6.0); + p.poly(&[(5.0, 8.3), (7.2, 10.5), (11.0, 6.0)], false); + } Logo => { // FigCraft's own mark: the curled tail of the chameleon on the app icon. One bold // stroke: a shaft along the bottom, then three half turns, each tighter than the last. @@ -1162,6 +1224,46 @@ pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, active: bool, tooltip: & resp } +/// The mark of a file of `kind` on tabs and file rows: a pen nib for design files, a sticky note +/// for boards. +pub fn file_mark(kind: figcraft_doc::FileKind) -> Icon { + match kind { + figcraft_doc::FileKind::Design => Icon::PenNib, + figcraft_doc::FileKind::Board => Icon::Board, + } +} + +/// SVG path data outlines on a `size`-unit box, painted into `rect`: each part filled, or +/// stroked `width` box units wide. Board shapes and stamp marks are drawn this way in the UI from +/// the same outlines the canvas has. +pub fn paint_outlines(painter: &Painter, rect: Rect, size: f64, parts: &[(&figcraft_geom::BezPath, bool)], color: Color32, width: f32) { + let s = rect.width().min(rect.height()) / size as f32; + let px = 1.0 / painter.ctx().pixels_per_point(); + let o = rect.center() - Vec2::splat(size as f32 * s / 2.0); + let p = P { painter, o, s, px, stroke: Stroke::new((width * s).max(px), color), color }; + for (path, filled) in parts { + let mut sub: Vec<(f32, f32)> = Vec::new(); + let flush = |sub: &mut Vec<(f32, f32)>, closed: bool| { + if sub.len() >= 2 { + if *filled { p.fill(sub) } else { p.path(sub, closed) } + } + sub.clear(); + }; + let mut closed = false; + figcraft_geom::kurbo::flatten(path.iter(), 0.25, |el| match el { + figcraft_geom::kurbo::PathEl::MoveTo(q) => { + flush(&mut sub, closed); + closed = false; + sub.push((q.x as f32, q.y as f32)); + } + figcraft_geom::kurbo::PathEl::LineTo(q) => sub.push((q.x as f32, q.y as f32)), + figcraft_geom::kurbo::PathEl::ClosePath => closed = true, + _ => {} + }); + flush(&mut sub, closed); + } +} + /// Every icon at the sizes the UI uses, labelled: for reviewing the set (`ui.set {iconGallery}`). pub fn gallery(ctx: &egui::Context) { let t = crate::theme::Tokens::get(ctx); @@ -1323,4 +1425,18 @@ mod tests { let inside = (0..h.len()).all(|i| side(i) >= 0.0) || (0..h.len()).all(|i| side(i) <= 0.0); assert!(inside, "the handle covers the blade's base at {base:?}"); } + + /// Each corner's icon shows its own corner: no two of the four draw alike. + #[test] + fn corner_icons_differ() { + let drawn: Vec = [Icon::CornerTopLeft, Icon::CornerTopRight, Icon::CornerBottomRight, Icon::CornerBottomLeft] + .iter() + .map(|&icon| format!("{:?}", shapes_at_32(icon))) + .collect(); + for (i, a) in drawn.iter().enumerate() { + for b in &drawn[i + 1..] { + assert_ne!(a, b); + } + } + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..da40bf0c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod a11y; pub mod assets_panel; pub mod bidi; +pub mod board_bar; pub mod branch_ui; pub mod canvas; pub mod cjk; @@ -69,6 +70,8 @@ pub mod versions; pub mod widgets; pub mod windows; +#[cfg(test)] +mod board_ui_tests; #[cfg(test)] mod theme_startup_tests; @@ -87,18 +90,24 @@ mod canvas_fixes_tests; #[cfg(test)] mod canvas_image_rect_tests; #[cfg(test)] +mod colorpicker_eyedropper_tests; +#[cfg(test)] mod colorpicker_selection_tests; #[cfg(test)] mod comments_tests; #[cfg(test)] mod component_props_reorder_tests; #[cfg(test)] +mod corner_individual_tests; +#[cfg(test)] mod cursors_tests; #[cfg(test)] mod design_paste_tests; #[cfg(test)] mod dev_mode_tests; #[cfg(test)] +mod devmode_tail_tests; +#[cfg(test)] mod duplicate_paint_tests; #[cfg(test)] mod export_preview_tests; @@ -151,6 +160,8 @@ mod measure_nudge_tests; #[cfg(test)] mod menu_aim_tests; #[cfg(test)] +mod menu_contrast_tests; +#[cfg(test)] mod menu_input_tests; #[cfg(test)] mod missing_fonts_tests; @@ -183,6 +194,8 @@ mod popup_width_tests; #[cfg(test)] mod present_links_tests; #[cfg(test)] +mod present_tab_tests; +#[cfg(test)] mod present_tests; #[cfg(test)] mod present_view_tests; @@ -228,6 +241,7 @@ mod windows_qa_tests; mod windows_tests; use std::collections::BTreeSet; +use std::ffi::OsStr; use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -469,15 +483,16 @@ pub const PANEL_DEFAULT: f32 = 240.0; pub const CANVAS_MIN: f32 = 160.0; /// The widths the side panels are drawn at in `avail` points (the window less the icon rail), from -/// the widths asked for (`left`: none while it is closed): each within its limits, and narrower -/// in a small window so the canvas keeps [`CANVAS_MIN`] (the right panel gives way last). -pub fn fit_panels(avail: f32, left: Option, right: f32) -> (f32, f32) { +/// the widths asked for (none while a panel is closed, which takes no room): each within its +/// limits, and narrower in a small window so the canvas keeps [`CANVAS_MIN`] (the right panel +/// gives way last). +pub fn fit_panels(avail: f32, left: Option, right: Option) -> (f32, f32) { let fit = |w: f32, range: &std::ops::RangeInclusive, room: f32| { let w = if w.is_finite() { w } else { PANEL_DEFAULT }; w.clamp(*range.start(), room.clamp(*range.start(), *range.end())) }; let left_min = left.map_or(0.0, |_| *LEFT_PANEL.start()); - let right = fit(right, &RIGHT_PANEL, avail - left_min - CANVAS_MIN); + let right = right.map_or(0.0, |r| fit(r, &RIGHT_PANEL, avail - left_min - CANVAS_MIN)); (left.map_or(0.0, |l| fit(l, &LEFT_PANEL, avail - right - CANVAS_MIN)), right) } @@ -544,10 +559,10 @@ pub struct UiState { /// A message at the bottom of the window and how long it has been showing. pub toast: Option<(String, ToastClock)>, pub present: Option, - /// A presentation kept alive while its design tab or Home is in front. - pub paused_present: Option, - /// The file that owns the presentation tab. - pub present_uid: Option, + pub presents: Vec, + /// The tab shown last. It is on screen while `present` has a value. + pub present_active: Option, + pub present_next: u64, /// The full-window presenter's options (how it sizes the prototype), kept between /// presentations. pub present_view: present::PresentView, @@ -577,6 +592,8 @@ pub struct UiState { pub dark: bool, pub follow_system: bool, pub tool_flyout: Option<&'static str>, + /// On a board: the Design panel is shown (boards hide it until asked for). + pub board_panel: bool, pub last_error: Option, /// Files dropped on the home screen or the canvas, opened once their card has been on screen /// ([`pending_open::pending_opens`]), and the frames it has been drawn. @@ -631,6 +648,9 @@ pub struct UiState { pub paste_event: bool, /// The window got the focus back since the last V release. pub refocused: bool, + /// A save shortcut pressed while a panel field had the keyboard: the field lets go (which + /// commits what was typed) and the command runs the next frame (`shortcuts::handle`). + pub after_field: Option<&'static str>, /// The modifiers held, as of the last modifier change in the raw events: a paste event doesn't /// carry them. pub held: egui::Modifiers, @@ -657,6 +677,12 @@ pub struct UiState { pub online: online::OnlineUi, /// The window was asked to close with unsaved changes: the question shows. pub confirm_close: bool, + /// The file a Save as started from the unsaved-changes question is saving: the window closes + /// once it is saved, and stays open if the dialog is cancelled or the save fails. + pub close_after_save_as: Option, + /// Save was answered: the files save next frame, once a panel field the click took the + /// keyboard from has committed what was typed (as [`UiState::after_field`]). + pub save_on_close: bool, } impl Default for UiState { @@ -699,8 +725,9 @@ impl Default for UiState { icon_gallery: false, toast: None, present: None, - paused_present: None, - present_uid: None, + presents: Vec::new(), + present_active: None, + present_next: 1, present_view: present::PresentView::default(), preview: None, picker: None, @@ -716,6 +743,7 @@ impl Default for UiState { dark: false, follow_system: false, tool_flyout: None, + board_panel: false, last_error: None, pending_opens: Vec::new(), pending_open_frames: 0, @@ -748,6 +776,7 @@ impl Default for UiState { v_pressed: false, paste_event: false, refocused: false, + after_field: None, held: egui::Modifiers::NONE, next_autosave: 0.0, auto_save_at: None, @@ -760,6 +789,8 @@ impl Default for UiState { live: live::LiveUi::default(), online: online::OnlineUi::default(), confirm_close: false, + close_after_save_as: None, + save_on_close: false, } } } @@ -937,6 +968,22 @@ impl FigApp { } } + /// Dev Mode (the Inspect tab): the canvas measures on hover without Alt and edits nothing; + /// entering it ends editing text or vectors. + fn sync_dev_mode(&mut self) { + let dev = self.ui.right_tab == RightTab::Inspect; + if dev && !self.session.tool.dev_mode { + let _ = self.session.drop_gesture(); + let editing = self.session.ds().map(|d| (d.text_edit.is_some(), d.vector_edit.is_some())).unwrap_or_default(); + for (on, cmd) in [(editing.0, "text.edit.end"), (editing.1, "vector.edit.end")] { + if on { + self.run(cmd, json!({})); + } + } + } + self.session.tool.dev_mode = dev; + } + /// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect /// don't draw (#307). Checked as the frame starts (the control channel) and after the panels /// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more. @@ -981,7 +1028,15 @@ impl FigApp { "dialog.missingFonts" => missing_fonts::request(self), "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, "panel.design" => self.ui.right_tab = RightTab::Design, + "board.editText" => { + if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of) + && let Err(e) = figcraft_engine::board::edit_text(&mut self.session, id) + { + self.toast(e.to_string()); + } + } "panel.prototype" => self.ui.right_tab = RightTab::Prototype, "panel.designPrototype" => { self.ui.right_tab = if self.ui.right_tab == RightTab::Prototype { RightTab::Design } else { RightTab::Prototype }; @@ -1009,7 +1064,7 @@ impl FigApp { } "panel.versionHistory" => versions::open(self), "view.devCompare" => { - self.ui.dev.compare = r.data.get("id").and_then(figcraft_engine::params::id_of).map(dev_compare::CompareView::new); + self.ui.dev.compare = dev_compare::CompareView::requested(&r.data); } "panel.libraries" => { self.ui.left_tab = LeftTab::Assets; @@ -1030,12 +1085,7 @@ impl FigApp { "dialog.colorProfile" => self.ui.color_profile = self.session.doc().ok().map(|d| d.color_profile), "present" => { if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) { - let mut p = present::Presenter::new(start); - p.view = self.ui.present_view; - self.ui.present = Some(p); - self.ui.paused_present = None; - self.ui.present_uid = self.session.docs.get(self.session.active).map(|d| d.uid); - self.ui.home = false; + tabs::open_present(self, start); } } "present.options" => { @@ -1142,7 +1192,7 @@ impl FigApp { pub fn save_as_dialog(&mut self) { let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return }; self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft", "fig"], |app, mut p| { - if p.extension().is_none_or(|e| e.is_empty()) { + if p.extension().is_none_or(OsStr::is_empty) { if !p.set_extension("figcraft") { app.toast(i18n::tr("Use the .figcraft or .fig extension.")); return; @@ -1159,7 +1209,7 @@ impl FigApp { return; } } - } else if !p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("figcraft") || e.eq_ignore_ascii_case("fig")) { + } else if !p.extension().and_then(OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("figcraft") || e.eq_ignore_ascii_case("fig")) { app.toast(i18n::tr("Use the .figcraft or .fig extension.")); return; } @@ -1178,7 +1228,7 @@ impl FigApp { .docs .get(tab) .and_then(|d| d.path.as_deref()) - .is_some_and(|path| !Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("fig"))); + .is_some_and(|path| !Path::new(path).extension().and_then(OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("fig"))); if native_path { if self.run("file.save", json!({})).is_some() { self.toast(i18n::tr("Saved")); @@ -1550,6 +1600,16 @@ impl FigApp { } } + /// Whether the right panel shows: never with the UI `hidden`; always on a design file; on a + /// board, which has no prototypes or Dev Mode (its tab stays Design), only when asked for. + fn right_panel_shown(&mut self, hidden: bool) -> bool { + let board = self.session.doc().is_ok_and(figcraft_engine::board::is_board); + if board { + self.ui.right_tab = RightTab::Design; + } + !hidden && (!board || self.ui.board_panel) + } + /// One frame of the window drawing now. pub(crate) fn frame(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -1575,9 +1635,9 @@ impl FigApp { } theme::set_doc_profile(&ctx, self.session.doc().map_or(figcraft_color::ColorProfile::Srgb, |d| d.color_profile)); widgets::set_nudge(&ctx, self.session.prefs.nudge_small, self.session.prefs.nudge_big); - // The Inspect tab measures on hover without Alt. - self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; + self.sync_dev_mode(); self.end_pen_outside_design(); + tabs::relink_presents(self); tabs::tab_bar(self, ui); if self.ui.present.is_some() { // A close request can arrive while the presenter fills the window. Let its unsaved @@ -1602,6 +1662,7 @@ impl FigApp { return; } let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false); + let right_open = self.right_panel_shown(hidden); if !hidden { egui::Panel::left("left_rail") .exact_size(56.0) @@ -1614,7 +1675,7 @@ impl FigApp { } // The panels are as wide as asked, less in a window too small for them and the canvas. let left_open = !hidden && !self.ui.left_collapsed; - let (left_w, right_w) = fit_panels(ui.available_width(), left_open.then_some(self.ui.left_width), self.ui.right_width); + let (left_w, right_w) = fit_panels(ui.available_width(), left_open.then_some(self.ui.left_width), right_open.then_some(self.ui.right_width)); let rows = ui.available_rect_before_wrap().y_range(); let mut edges: Vec<(&str, f32, f32, f32)> = Vec::new(); if left_open { @@ -1633,7 +1694,7 @@ impl FigApp { layers::left_panel(self, ui) }); } - if !hidden { + if right_open { edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0)); egui::Panel::right("right_panel") .resizable(false) @@ -1764,7 +1825,7 @@ impl FigApp { "focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard}, "reduceMotion": self.reduce_motion(), "presenting": self.ui.present.is_some(), - "presenter": self.ui.present.as_ref().or(self.ui.paused_present.as_ref()).map(present::Presenter::inspect), + "presenter": tabs::shown_or_paused(self).map(present::Presenter::inspect), "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect}, "preview": self.ui.preview.as_ref().map(|p| p.player.inspect()), "mainMenu": self.ui.main_menu, @@ -1772,7 +1833,7 @@ impl FigApp { "variables": self.ui.variables.is_some(), "versionHistory": self.ui.versions.open, "branchReview": self.ui.branch_review.as_ref().map(|r| json!({"id": r.id, "picks": r.picks, "mode": if r.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }})), - "devCompare": self.ui.dev.compare.as_ref().map(|c| json!({"id": c.id, "mode": if c.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }, "split": c.split})), + "devCompare": self.ui.dev.compare.as_ref().map(|c| json!({"id": c.id, "mode": if c.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }, "split": c.split, "version": c.version})), "devFocus": self.session.ds().ok().and_then(figcraft_engine::DocState::focused), "annotationFilter": self.ui.dev.categories.id(), "colorProfileDialog": self.ui.color_profile.map(|p| p.id()), @@ -1818,6 +1879,7 @@ impl FigApp { state["uiScale"] = json!({"choice": self.session.prefs.ui_scale, "zoom": self.ui.ui_scale.zoom, "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); state["pendingOpens"] = json!(self.ui.pending_opens.len()); state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); + state["presentTabs"] = tabs::present_tabs_inspect(self); state } } @@ -1827,13 +1889,13 @@ pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "json", "svg", /// Whether `path` names a file type that opens as a document (the extension, in any case). pub(crate) fn opens(path: &Path) -> bool { - path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) + path.extension().and_then(OsStr::to_str).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } /// Whether `path` is a whole document (`.figcraft`, `.fig`, file JSON) rather than an image or SVG: /// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one. pub(crate) fn is_document(path: &Path) -> bool { - path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) + path.extension().and_then(OsStr::to_str).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) } /// Reads the contents of dropped file `name` that has no file on disk to read by path. @@ -1877,5 +1939,8 @@ pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) { #[cfg(test)] mod selection_spacing_tests; +#[cfg(test)] +mod type_settings_tests; + #[cfg(test)] mod typography_scrub_tests; diff --git a/crates/ui-egui/src/live_server_tests.rs b/crates/ui-egui/src/live_server_tests.rs index 9c2cb22c..4a92edf0 100644 --- a/crates/ui-egui/src/live_server_tests.rs +++ b/crates/ui-egui/src/live_server_tests.rs @@ -25,7 +25,7 @@ fn focused(h: &Harness, id: &str) -> bool { } /// Joined through a server's link with `access`: the welcome and the document (a new tab). -fn joined(h: &mut Harness, access: Access) { +pub(crate) fn joined(h: &mut Harness, access: Access) { h.app.session.execute("collab.join", &json!({"link": "figcraft://join?host=127.0.0.1:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv"})).unwrap(); let mut server = Server::new(h.app.session.doc().unwrap().clone(), "", Limits::default()); let ClientMsg::Hello(me) = Client::hello("f", "", "Ada", "") else { panic!("no hello") }; diff --git a/crates/ui-egui/src/menu_contrast_tests.rs b/crates/ui-egui/src/menu_contrast_tests.rs new file mode 100644 index 00000000..f3a9b1bd --- /dev/null +++ b/crates/ui-egui/src/menu_contrast_tests.rs @@ -0,0 +1,84 @@ +//! Panel dropdowns (the prototype device picker, Preferences, blend modes…) draw on the theme's +//! popup fill, so their rows' text must read on it in both themes (#972): drawn headless. + +use egui::{Color32, Event, Order, PointerButton, Pos2, RawInput, Rect, Shape, pos2, vec2}; + +use crate::right::{menu_check, menu_item, menu_popup}; +use crate::theme::{self, Tokens}; + +fn input(t: &mut f64, events: Vec) -> RawInput { + *t += 1.0 / 60.0; + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(*t), events, ..Default::default() } +} + +fn press(p: Pos2, pressed: bool) -> Vec { + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }] +} + +fn color(x: Color32) -> figcraft_color::Color { + figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0) +} + +/// The text painted on the foreground (menu) layers: (text, colour). +fn menu_texts(out: &egui::FullOutput) -> Vec<(String, Color32)> { + fn walk(s: &Shape, out: &mut Vec<(String, Color32)>) { + match s { + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + Shape::Text(t) => out.push((t.galley.text().to_string(), t.fallback_color)), + _ => {} + } + } + let mut texts = Vec::new(); + for c in &out.shapes { + walk(&c.shape, &mut texts); + } + texts +} + +/// Open a dropdown with `tokens` and return its rows' text colours, with the pointer away from them. +fn rows(tokens: Tokens) -> Vec<(String, Color32)> { + let ctx = egui::Context::default(); + theme::apply(&ctx, tokens); + let mut t = 0.0; + let mut frame = |events: Vec| { + let mut out = ctx.run_ui(input(&mut t, events), |ui| { + let (_, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), egui::Sense::click()); + menu_popup(&resp).show(|ui| { + menu_check(ui, true, "iPhone 17 Pro Max", ""); + menu_check(ui, false, "Pixel 9", ""); + menu_item(ui, "Copy", "Cmd+C"); + }); + }); + out.textures_delta.clear(); + out + }; + let at = pos2(12.0, 12.0); + frame(vec![]); + frame(press(at, true)); + frame(press(at, false)); + // Past the popup's fade-in, so the colours are the ones it settles on. + for _ in 0..30 { + frame(vec![Event::PointerMoved(pos2(790.0, 590.0))]); + } + let out = frame(vec![]); + assert!(ctx.memory(|m| m.areas().visible_layer_ids().iter().any(|l| l.order == Order::Foreground)), "the menu opened"); + menu_texts(&out) +} + +#[test] +fn dropdown_rows_read_on_the_popup_in_both_themes() { + for tokens in [Tokens::light(), Tokens::dark()] { + // The popup's own fill (`window_fill`), what the rows are drawn on. + let fill = color(tokens.panel); + let texts = rows(tokens); + let keys = crate::toolbar::pretty_shortcut("Cmd+C"); + // Labels at WCAG AA for text; the dimmer shortcut at least 3:1. + for (label, min) in [("iPhone 17 Pro Max", 4.5), ("Pixel 9", 4.5), ("Copy", 4.5), (keys.as_str(), 3.0)] { + let Some((_, col)) = texts.iter().find(|(s, _)| s.trim() == label || s.ends_with(label)) else { + panic!("{label} painted (dark {}): {texts:?}", tokens.dark) + }; + let ratio = color(*col).contrast(fill); + assert!(ratio >= min, "{label} at {ratio:.2}:1 on the popup (dark {}): {texts:?}", tokens.dark); + } + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9659c6b3..c48fa401 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -35,9 +35,13 @@ pub const UI_COMMANDS: &[&str] = &[ /// `event_text` is the text the platform handed over with the paste event (the browser has no /// other way to read it). `at`: the page point to paste at (Paste here); `None` pastes in the /// middle of the view. +/// In Dev Mode only a comment's link pastes (it opens the thread); everything else would edit. pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) { // Only HTML that holds readable design data takes this path; other HTML pastes as before. if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) { + if dev_mode_refuses(app, "edit.paste") { + return; + } let mut params = json!({"html": html}); if let Some((x, y)) = at { params["x"] = json!(x); @@ -58,6 +62,9 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) let (cx, cy) = at.or_else(|| app.session.ds().ok().map(|d| (d.view.cx, d.view.cy))).unwrap_or((0.0, 0.0)); if !ours { if let Some((png, w, h)) = clipboard_png(app) { + if dev_mode_refuses(app, "edit.paste") { + return; + } use base64::Engine as _; let b64 = base64::engine::general_purpose::STANDARD.encode(png); // Centred in the view; Paste here puts its top-left at the point, as it does layers. @@ -67,20 +74,28 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) } // Text from elsewhere when it's newer than our copy, or when we have nothing to paste. if let Some(text) = sys_text.filter(|_| app.ui.last_copy.is_some() || app.session.clipboard.nodes.is_empty()) { - // A comment's link opens its thread. - if text.trim().starts_with("figcraft://comment/") { - app.run("comment.open", json!({"link": text.trim()})); - } else if figcraft_engine::looks_like_svg(&text) { - use base64::Engine as _; - let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - app.run_user("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": at.is_none()})); - } else { - app.run("text.create", json!({"text": text, "x": cx, "y": cy})); - } - return; + return paste_text(app, &text, (cx, cy), at.is_none()); } } - app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y}))); + if !dev_mode_refuses(app, "edit.paste") { + app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y}))); + } +} + +/// Paste text from another app at `(cx, cy)` (`center`: centred there): a comment's link opens its +/// thread, SVG imports, anything else becomes a text layer. +fn paste_text(app: &mut FigApp, text: &str, (cx, cy): (f64, f64), center: bool) { + if text.trim().starts_with("figcraft://comment/") { + app.run("comment.open", json!({"link": text.trim()})); + } else if dev_mode_refuses(app, "edit.paste") { + // Dev Mode: the note says why nothing was pasted. + } else if figcraft_engine::looks_like_svg(text) { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + app.run_user("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": center})); + } else { + app.run("text.create", json!({"text": text, "x": cx, "y": cy})); + } } /// The system clipboard's image as PNG bytes, with its size. @@ -92,6 +107,18 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, u32, u32)> { Some((out.into_inner(), w, h)) } +/// Dev Mode (the Inspect tab) refuses command `id` from the menus, shortcuts and clipboard: it +/// edits the design ([`figcraft_engine::devinspect::dev_mode_allows`]). Says so when it does. +/// shortcut: the layers panel (rename, reorder, hide, lock) and files dropped on the canvas still +/// edit in Dev Mode; route them through here if people trip over it. +pub fn dev_mode_refuses(app: &mut FigApp, id: &str) -> bool { + let refused = app.ui.right_tab == crate::RightTab::Inspect && !figcraft_engine::devinspect::dev_mode_allows(id); + if refused { + app.toast(tr("Dev Mode only inspects: switch to Design (Shift+D) to edit")); + } + refused +} + /// The Actions palette's tab. #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum PaletteTab { @@ -115,6 +142,9 @@ pub fn record_recent(app: &mut FigApp, key: &str) { /// Run a menu, shortcut or palette action by id: actions that need the UI (dialogs, panels, the /// clipboard) are handled here, the rest go to the engine. pub fn invoke(app: &mut FigApp, id: &str) { + if dev_mode_refuses(app, id) { + return; + } match id { // Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab // removes that interaction, not the layer (#767). @@ -133,6 +163,10 @@ pub fn invoke(app: &mut FigApp, id: &str) { app.run(id, json!({})); } "file.open" => app.open_dialog(), + // From the keyboard or a menu, typing goes on in the new sticky. + "board.nextSticky" => { + app.run(id, json!({"edit": true})); + } "plugin.import" => crate::plugins_ui::import_dialog(app), "file.import" => app.import_dialog(false), "file.placeImage" => app.import_dialog(true), @@ -1032,7 +1066,7 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); // Same centre and width as the toolbar pill (it centres on the canvas, not the window), // floating 8 px above it. Fixed size: switching tabs never resizes the panel. - let bar_w = crate::toolbar::main_width(); + let bar_w = crate::toolbar::bar_width(app); let bar = crate::toolbar::pill_rect(app.canvas.rect, bar_w); let area = Rect::from_min_max(pos2(bar.min.x, bar.min.y - 8.0 - (PALETTE_H + 16.0)), pos2(bar.max.x, bar.min.y - 8.0)); let mut run: Option = None; diff --git a/crates/ui-egui/src/panel_width_tests.rs b/crates/ui-egui/src/panel_width_tests.rs index a5a84d04..882a97a7 100644 --- a/crates/ui-egui/src/panel_width_tests.rs +++ b/crates/ui-egui/src/panel_width_tests.rs @@ -67,16 +67,17 @@ fn panels_fit_the_window() { let (l0, l1, r0, r1) = (*LEFT_PANEL.start(), *LEFT_PANEL.end(), *RIGHT_PANEL.start(), *RIGHT_PANEL.end()); // (room beside the icon rail, left asked (none: closed), right asked) → (left, right) drawn let rows = [ - ((1384.0, Some(240.0), 240.0), (240.0, 240.0), "as asked in a wide window"), - ((1384.0, Some(100.0), 100.0), (l0, r0), "never narrower than their least"), - ((1384.0, Some(900.0), 900.0), (l1, r1), "never wider than their most"), - ((1384.0, None, 300.0), (0.0, 300.0), "the left panel closed"), - ((744.0, Some(240.0), 240.0), (240.0, 240.0), "the smallest window with the default widths"), - ((744.0, Some(480.0), 480.0), (l0, 744.0 - l0 - CANVAS_MIN), "the smallest window: the left panel gives way first, then the right"), - ((744.0, Some(480.0), 240.0), (744.0 - 240.0 - CANVAS_MIN, 240.0), "the left panel gives the canvas its room"), - ((744.0, None, 480.0), (0.0, 480.0), "with the left panel closed the right one has room"), - ((300.0, Some(240.0), 240.0), (l0, r0), "a window smaller than any layout: the least widths"), - ((1384.0, Some(f32::NAN), f32::INFINITY), (PANEL_DEFAULT, PANEL_DEFAULT), "junk widths: the default"), + ((1384.0, Some(240.0), Some(240.0)), (240.0, 240.0), "as asked in a wide window"), + ((1384.0, Some(100.0), Some(100.0)), (l0, r0), "never narrower than their least"), + ((1384.0, Some(900.0), Some(900.0)), (l1, r1), "never wider than their most"), + ((1384.0, None, Some(300.0)), (0.0, 300.0), "the left panel closed"), + ((744.0, Some(240.0), Some(240.0)), (240.0, 240.0), "the smallest window with the default widths"), + ((744.0, Some(480.0), Some(480.0)), (l0, 744.0 - l0 - CANVAS_MIN), "the smallest window: the left panel gives way first, then the right"), + ((744.0, Some(480.0), Some(240.0)), (744.0 - 240.0 - CANVAS_MIN, 240.0), "the left panel gives the canvas its room"), + ((744.0, None, Some(480.0)), (0.0, 480.0), "with the left panel closed the right one has room"), + ((300.0, Some(240.0), Some(240.0)), (l0, r0), "a window smaller than any layout: the least widths"), + ((1384.0, Some(f32::NAN), Some(f32::INFINITY)), (PANEL_DEFAULT, PANEL_DEFAULT), "junk widths: the default"), + ((744.0, Some(480.0), None), (480.0, 0.0), "the right panel closed (a board's): the left one keeps its width"), ]; for ((avail, left, right), want, why) in rows { assert_eq!(fit_panels(avail, left, right), want, "{why}"); @@ -85,7 +86,7 @@ fn panels_fit_the_window() { for avail in [744.0, 900.0, 1384.0] { for left in [None, Some(l0), Some(300.0), Some(l1)] { for right in [r0, 333.0, r1] { - let (l, r) = fit_panels(avail, left, right); + let (l, r) = fit_panels(avail, left, Some(right)); assert!(avail - l - r >= CANVAS_MIN, "{avail} {left:?} {right}: canvas {}", avail - l - r); } } diff --git a/crates/ui-egui/src/plugin_panel_tests.rs b/crates/ui-egui/src/plugin_panel_tests.rs index f154c629..e4bc6d4f 100644 --- a/crates/ui-egui/src/plugin_panel_tests.rs +++ b/crates/ui-egui/src/plugin_panel_tests.rs @@ -13,7 +13,7 @@ use crate::layers_drag_tests::Ui; /// A plugin whose first call shows a panel with every kind of control and whose later calls (the /// panel's events) make a rectangle. -fn panel_module() -> Vec { +pub(crate) fn panel_module() -> Vec { let spec = json!({"title": "Test panel", "controls": [ {"type": "note", "label": "Hello from the plugin"}, {"type": "number", "name": "size", "label": "Size", "value": 10}, diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 9c0f1608..fae1e1a7 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -4,8 +4,9 @@ //! The player works on its own copy of the document (interactive components, variables and scroll //! offsets change it without touching the user's file) and copies the editor's document again //! whenever it changes, so the preview pane follows edits live. On a device, the presented frame -//! is sized to the device's screen ([`fit_viewport`]): what doesn't fit is clipped, or scrolls when -//! the frame's overflow allows it. A responsive prototype's frame is instead resized to the screen +//! is sized to the device's screen ([`fit_viewport`]): a frame bigger than the screen scrolls +//! through its whole size (as in Figma, with no overflow setting), while content past the frame's +//! own edges scrolls only as its overflow allows and is otherwise clipped. A responsive prototype's frame is instead resized to the screen //! (the device's, else the player's whole area) and laid out again, its constraints and auto layout //! responding (`figcraft_engine::cmd::proto::respond`, on the player's copy, from the design each //! time). Device bodies are drawn in code in the device's colour. Every frame is rendered through @@ -330,6 +331,11 @@ impl Presenter { true } + /// The frame the presentation started from (Restart goes back there). + pub fn start(&self) -> NodeId { + self.start + } + pub fn new(start: NodeId) -> Self { Self { current: start, @@ -511,6 +517,16 @@ impl Presenter { self.rev += 1; } + /// After an action changed layers of `design` (since `before`): text resizes to what it shows, + /// instances follow and auto layout reflows, as after an edit on the canvas; then + /// [`relayout`](Self::relayout). + fn settle(&mut self, before: &Document) { + if let Some(d) = self.design.as_mut() { + figcraft_engine::normalize(d, before); + } + self.relayout(); + } + /// Responsive prototypes: resize the presented frame to a screen of `size` (None: frames /// keep their size). Lays out again only when it changes. fn respond_to(&mut self, size: Option<(f64, f64)>) { @@ -879,28 +895,31 @@ impl Presenter { } self.begin(tr, dur, easing, now); let Some(d) = self.design.as_mut() else { return false }; + let before = d.clone(); if let Some(n) = d.node_mut(inst) && let NodeKind::Instance { component, .. } = &mut n.kind { *component = *dest; } figcraft_engine::components::sync_instance(d, inst); - self.relayout(); + self.settle(&before); true } Action::SetVariable { variable, value, expr } => { let Some(d) = self.design.as_mut() else { return false }; + let before = d.clone(); let set = figcraft_engine::proto_run::set_variable(d, node, variable, value, expr.as_ref()); if set { - self.relayout(); + self.settle(&before); } set } Action::SetVariableMode { collection, mode } => { let Some(d) = self.design.as_mut() else { return false }; + let before = d.clone(); let set = figcraft_engine::proto_run::set_mode(d, self.current, collection, mode); if set { - self.relayout(); + self.settle(&before); } set } @@ -1170,11 +1189,13 @@ pub fn scroll_range(doc: &Document, frame: NodeId) -> (f64, f64) { /// How far `frame`'s content can scroll when `viewport` (page units, from its top-left) of it /// shows, as a device's screen shows a top-level frame: the frame itself is content then, so a -/// frame taller than the screen scrolls through its whole height. Without a viewport the frame -/// shows at its own size and what overflows it scrolls. Only the axes its overflow allows scroll. +/// frame bigger than the screen scrolls through its whole size whatever its overflow (as in +/// Figma, a top-level frame needs no overflow setting for that). Without a viewport the frame +/// shows at its own size and what overflows it scrolls. Content past the frame's own edges +/// scrolls only on the axes its overflow allows. pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64)>) -> (f64, f64) { let Some(n) = doc.node(frame) else { return (0.0, 0.0) }; - if n.overflow == Overflow::None { + if n.overflow == Overflow::None && viewport.is_none() { return (0.0, 0.0); } let (mut x1, mut y1) = (0.0f64, 0.0f64); @@ -1191,6 +1212,13 @@ pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64 x1 += l.padding[1]; y1 += l.padding[2]; } + // Axes the overflow doesn't scroll clip what lies past the frame's edges. + if !matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { + x1 = 0.0; + } + if !matches!(n.overflow, Overflow::Vertical | Overflow::Both) { + y1 = 0.0; + } let (vw, vh) = match viewport { Some(v) => { x1 = x1.max(n.width); @@ -1199,8 +1227,7 @@ pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64 } None => (n.width, n.height), }; - let rx = if matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { (x1 - vw).max(0.0) } else { 0.0 }; - let ry = if matches!(n.overflow, Overflow::Vertical | Overflow::Both) { (y1 - vh).max(0.0) } else { 0.0 }; + let (rx, ry) = ((x1 - vw).max(0.0), (y1 - vh).max(0.0)); (if rx.is_finite() { rx } else { 0.0 }, if ry.is_finite() { ry } else { 0.0 }) } @@ -1642,12 +1669,26 @@ enum BarAction { Inspect, } +pub struct PresentTab { + pub id: u64, + /// Empty while the tab is on screen: then the player is in `app.ui.present`. + pub pres: Option, + pub doc_uid: u64, + pub path: Option, + pub page: NodeId, + pub file: String, + pub page_name: String, +} + /// The full-window presenter (while `app.ui.present` is set). Escape or Close ends it. pub fn show(app: &mut FigApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); let now = ctx.input(|i| i.time); let Some(mut pres) = app.ui.present.take() else { return }; - if let Ok(ds) = app.session.ds() { + // A tab can play a file that is closed or not in front. Then it must not read or change + // the file in front. + let live = crate::tabs::present_owner_active(app); + if live && let Ok(ds) = app.session.ds() { pres.sync(&ds.doc, now); } poll_pads(app, &mut pres, &ctx); @@ -1684,7 +1725,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { Some(BarAction::Inspect) => pres.view.inspect = !pres.view.inspect, _ => {} } - if pres.view.comments + if live + && pres.view.comments && let Some((frame, place, clip)) = pres.shown { crate::comments::presenter(app, &ctx, frame, place, clip); @@ -1694,17 +1736,20 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { finish(app, &mut pres); // The options carry over to the next presentation. app.ui.present_view = pres.view; - if let Some(BarAction::Responsive(on)) = action { + if live && let Some(BarAction::Responsive(on)) = action { app.run("proto.settings", json!({"responsive": on})); } - if let Some(device) = picked_device { + if live && let Some(device) = picked_device { choose_device(app, &mut pres, &device); } - if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.present_uid = None; - return; - } app.ui.present = Some(pres); + if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if let Some(id) = app.ui.present_active { + crate::tabs::close_present(app, id); + } else { + app.ui.present = None; + } + } } /// Device choices in the presenter use the screen's real dimensions and reflow a copy of the @@ -2356,7 +2401,7 @@ fn scrollers_at(p: &Presenter, base: &Document, disp: &Document, root: NodeId, p let mut out: Vec = disp .subtree(root) .into_iter() - .filter(|f| disp.node(*f).is_some_and(|n| n.visible && n.overflow != Overflow::None)) + .filter(|f| disp.node(*f).is_some_and(|n| n.visible && (n.overflow != Overflow::None || *f == root))) .filter(|f| disp.world_bounds(*f).is_some_and(|b| place.rect(b).contains(q))) .filter(|f| p.range(base, *f) != (0.0, 0.0)) .collect(); @@ -3144,9 +3189,11 @@ mod tests { assert_eq!(disp.world_bounds(list).unwrap().y0, 800.0); p.scroll_to(0.0, 99_999.0); assert_eq!(p.scroll[&a], (0.0, 1148.0), "clamped to the range"); - // Without scrolling the screen only clips; without a device the frame shows whole. + // A top-level frame taller than the screen scrolls without an overflow setting (its own + // range still clips nested content); without a device the frame shows whole. d.get_mut(a).unwrap().overflow = Overflow::None; - assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 0.0)); + assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 1148.0)); + assert_eq!(scroll_range(&d, a), (0.0, 0.0)); if let Some(pg) = d.page_of(a) && let NodeKind::Page { prototype, .. } = &mut d.get_mut(pg).unwrap().kind { diff --git a/crates/ui-egui/src/present_behaviour_tests.rs b/crates/ui-egui/src/present_behaviour_tests.rs index 791e0d3f..e9a036f2 100644 --- a/crates/ui-egui/src/present_behaviour_tests.rs +++ b/crates/ui-egui/src/present_behaviour_tests.rs @@ -454,6 +454,99 @@ fn interactive_components_change_in_place() { } } +/// A hugging auto layout frame (in `parent`, else on the page) holding text `chars`: (frame, text). +fn hugging_label(s: &mut Session, parent: Option, name: &str, x: f64, chars: &str) -> (NodeId, NodeId) { + let f = id(&ex(s, "frame.create", json!({"x": x, "y": 600, "width": 60, "height": 30, "name": name, "parent": parent}))); + let t = id(&ex(s, "text.create", json!({"text": chars, "x": 0, "y": 0, "parent": f}))); + ex(s, "node.set", json!({"ids": [f], "props": {"layout": "horizontal", "padding": 8, "sizingH": "hug", "sizingV": "hug"}})); + (f, t) +} + +fn size(d: &Document, n: NodeId) -> (f64, f64) { + d.node(n).map(|n| (n.width, n.height)).unwrap() +} + +#[test] +fn change_to_lays_out_the_instance_around_its_own_content() { + // Change to shows the other variant in place: the instance keeps its overrides (a longer + // label), so it is laid out around them, as switching the variant on the canvas does. + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let variants: Vec = ["Default", "Hover"] + .iter() + .enumerate() + .map(|(k, state)| { + let (f, _) = hugging_label(&mut s, None, &format!("State={state}"), k as f64 * 150.0, "Button"); + id(&ex(&mut s, "component.create", json!({"ids": [f]}))) + }) + .collect(); + let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()}))); + on(&mut s, variants[0], json!({"type": "onClick"}), json!({"type": "changeTo", "dest": variants[1]}), json!({"type": "instant"}), 0); + let screen = frame(&mut s, "Screen", 0.0, 400.0, 300.0); + let button = id(&ex(&mut s, "instance.create", json!({"component": set, "x": 20, "y": 20, "parent": screen}))); + let label = s.doc().unwrap().node(button).unwrap().children[0]; + ex(&mut s, "text.set", json!({"id": label, "characters": "A much longer label"})); + let (main, canvas, canvas_label) = { + let d = s.doc().unwrap(); + (size(d, variants[1]), size(d, button), size(d, label)) + }; + // Without any font nothing is measured and both sizes are the main's. + if figcraft_text::layout_node(s.doc().unwrap().node(label).unwrap()).is_some_and(|(l, _)| !l.glyphs.is_empty()) { + assert!(canvas.0 > main.0 + 20.0, "on the canvas the instance hugs its longer label"); + } + let mut p = player(s.doc().unwrap().clone(), screen); + assert!(p.fire(button, 0, None, 1.0)); + let d = p.doc.as_ref().unwrap(); + assert!(matches!(d.node(button).unwrap().kind, NodeKind::Instance { component, .. } if component == variants[1])); + assert_eq!((size(d, button), size(d, label)), (canvas, canvas_label), "the Hover variant, laid out around the instance's label"); +} + +/// A screen holding a hugging chip whose text is bound to string variable `label` of collection +/// "Copy" (modes A "Short" and B "A much longer label"), the chip's click running `action`: +/// (session, screen, chip, text). +fn bound_chip(action: Value) -> (Session, NodeId, NodeId, NodeId) { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Copy", "modes": ["A", "B"]})); + ex( + &mut s, + "variable.create", + json!({"collection": "Copy", "name": "label", "type": "string", "values": {"A": "Short", "B": "A much longer label"}}), + ); + let screen = frame(&mut s, "Screen", 0.0, 400.0, 300.0); + let (chip, text) = hugging_label(&mut s, Some(screen), "Chip", 20.0, "Short"); + ex(&mut s, "variable.bind", json!({"ids": [text], "field": "characters", "variable": "label"})); + on(&mut s, chip, json!({"type": "onClick"}), action, json!({"type": "instant"}), 0); + (s, screen, chip, text) +} + +#[test] +fn set_variable_and_set_mode_lay_out_the_text_they_change() { + // A text bound to a string variable shows what an interaction sets, measured: the hugging + // chip around it grows with it, to the size the canvas gives the same text. + let cases = [ + ( + json!({"type": "setVariable", "variable": "label", "value": "A much longer label"}), + "variable.set", + json!({"variable": "label", "value": "A much longer label"}), + ), + (json!({"type": "setVariableMode", "collection": "Copy", "mode": "B"}), "variable.setMode", json!({"collection": "Copy", "mode": "B"})), + ]; + for (action, canvas_cmd, canvas_params) in cases { + let (mut s, screen, chip, text) = bound_chip(action.clone()); + let mut p = player(s.doc().unwrap().clone(), screen); + assert!(p.fire(chip, 0, None, 1.0), "{action}"); + let page = s.doc().unwrap().page_of(screen).unwrap(); + let mut params = canvas_params; + if canvas_cmd == "variable.setMode" { + params["ids"] = json!([page]); + } + ex(&mut s, canvas_cmd, params); + let (d, canvas) = (p.doc.as_ref().unwrap(), s.doc().unwrap()); + assert_eq!((size(d, chip), size(d, text)), (size(canvas, chip), size(canvas, text)), "{action}: the chip grows with its text"); + } +} + #[test] fn a_drag_started_transition_follows_the_pointer() { let (mut u, hot) = one_interaction(json!({"type": "onDrag"}), json!({"type": "push", "direction": "left"}), 400); diff --git a/crates/ui-egui/src/present_tab_tests.rs b/crates/ui-egui/src/present_tab_tests.rs new file mode 100644 index 00000000..1868babb --- /dev/null +++ b/crates/ui-egui/src/present_tab_tests.rs @@ -0,0 +1,193 @@ +use egui::accesskit::{Node, Role}; +use egui::{Key, Pos2, pos2}; +use figcraft_doc::proto::PrototypeSettings; +use serde_json::{Value, json}; + +use crate::present_tests::{Ui, id, setup}; + +const FILE_TAB: Pos2 = pos2(100.0, 20.0); + +fn click(u: &mut Ui, at: Pos2) { + u.button(at, true); + u.button(at, false); + u.frame(vec![]); +} + +fn tabs(u: &Ui) -> Value { + u.app.inspect()["presentTabs"].clone() +} + +fn presenting(u: &Ui) -> bool { + u.app.inspect()["presenting"] == json!(true) +} + +fn nodes(u: &mut Ui) -> Vec { + u.ctx.enable_accesskit(); + u.frame(vec![]); + u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect() +} + +fn centre(n: &Node) -> Pos2 { + let b = n.bounds().unwrap(); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click_label(u: &mut Ui, label: &str) { + let all = nodes(u); + let n = all.iter().find(|n| n.label().is_some_and(|l| l.trim() == label)).unwrap_or_else(|| panic!("no {label:?} on screen")); + click(u, centre(n)); +} + +/// The presenter bar's Options button: it has no label, and sits right of Show comments. +fn open_options(u: &mut Ui) { + let all = nodes(u); + let comments = all.iter().find(|n| n.label() == Some("Show comments (Shift+C)")).unwrap().bounds().unwrap(); + let options = all + .iter() + .filter(|n| n.role() == Role::Unknown && n.label().is_none()) + .filter(|n| n.bounds().is_some_and(|b| b.y0 == comments.y0 && b.x0 > comments.x1)) + .min_by(|a, b| a.bounds().unwrap().x0.total_cmp(&b.bounds().unwrap().x0)) + .expect("the Options button"); + click(u, centre(options)); +} + +fn front_settings(u: &Ui) -> PrototypeSettings { + let page = u.app.session.page().unwrap(); + crate::present::page_settings(u.app.session.doc().unwrap(), page) +} + +/// The middle of presentation tab `k` and of its close button, after `files` file tabs named +/// "Untitled". +fn present_tab(u: &Ui, files: usize, k: usize) -> (Pos2, Pos2) { + let width = |chars: usize, lo: f32, hi: f32| (12.0 * 0.6 * chars as f32 + 64.0).clamp(lo, hi); + let mut x = 52.0 + files as f32 * width("Untitled".len(), 120.0, 220.0); + let names: Vec = tabs(u).as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect(); + for n in names.iter().take(k) { + x += width(n.chars().count(), 140.0, 260.0); + } + let w = width(names[k].chars().count(), 140.0, 260.0); + (pos2(x + w / 2.0, 20.0), pos2(x + w - 16.0, 20.0)) +} + +#[test] +fn the_tab_is_named_after_the_file_and_page_and_its_x_closes_it() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + assert!(presenting(&u)); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": true}])); + click(&mut u, FILE_TAB); + assert!(!presenting(&u)); + assert_eq!(tabs(&u)[0]["active"], false); + let (tab, close) = present_tab(&u, 1, 0); + click(&mut u, tab); + assert!(presenting(&u)); + click(&mut u, close); + assert_eq!((presenting(&u), tabs(&u)), (false, json!([]))); + assert!(!u.app.ui.home, "back to its file"); +} + +#[test] +fn play_again_shows_the_tab_as_it_was_and_another_page_opens_a_new_one() { + let (mut u, a, b, _) = setup(); + u.run("proto.present", json!({"start": a})); + u.key(pos2(720.0, 470.0), Key::ArrowRight); + click(&mut u, FILE_TAB); + u.run("proto.present", json!({"start": a})); + assert_eq!(tabs(&u).as_array().unwrap().len(), 1, "same page: the same tab"); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "it shows the prototype as it was"); + u.run("proto.present", json!({"start": b})); + assert_eq!(tabs(&u).as_array().unwrap().len(), 1); + assert_eq!(u.app.inspect()["presenter"]["history"], json!([]), "another start frame starts again"); + + click(&mut u, FILE_TAB); + let page = id(&u.run("page.add", json!({"name": "Second"}))); + u.run("page.select", json!({"id": page})); + let c = id(&u.run("node.create", json!({"type": "frame", "name": "C", "x": 0, "y": 0, "width": 200, "height": 200}))); + u.run("proto.present", json!({"start": c})); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": false}, {"name": "Untitled - Second", "active": true}])); + let (first, _) = present_tab(&u, 1, 0); + click(&mut u, first); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": true}, {"name": "Untitled - Second", "active": false}])); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b)); +} + +#[test] +fn the_presentation_plays_on_when_its_file_closes() { + let (mut u, a, b, _) = setup(); + let hot = id(&u.run("node.create", json!({"type": "rectangle", "parent": a, "x": 0, "y": 0, "width": 393, "height": 80}))); + u.run("proto.interaction.add", json!({"id": hot, "dest": b})); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.close", json!({"index": 0, "force": true})); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": false}])); + let (tab, _) = present_tab(&u, 0, 0); + click(&mut u, tab); + assert!(presenting(&u)); + let spot = u.app.inspect()["presenter"]["hotspots"][0]["rect"].clone(); + let r: Vec = spot.as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect(); + click(&mut u, pos2((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0)); + u.frames(60, Vec::new); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "the prototype still plays"); + u.key(pos2(700.0, 500.0), Key::Escape); + assert_eq!(tabs(&u), json!([])); + assert!(u.app.ui.home, "no file to go back to: Home"); +} + +#[test] +fn a_tab_whose_file_closed_leaves_the_file_in_front_alone() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.new", json!({})); + u.run("file.close", json!({"index": 0, "force": true})); + let (tab, _) = present_tab(&u, 1, 0); + click(&mut u, tab); + u.frames(5, Vec::new); + assert!(presenting(&u)); + assert!(!crate::tabs::present_owner_active(&u.app)); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "it keeps playing its own copy"); + let before = front_settings(&u); + click_label(&mut u, "iPhone 16"); + click_label(&mut u, "iPhone 17 402×874"); + assert!(presenting(&u)); + for size in ["Fixed size", "Responsive"] { + open_options(&mut u); + click_label(&mut u, size); + } + assert_eq!(front_settings(&u), before, "picking a device or Responsive leaves the file in front alone"); + assert!(!u.app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn a_saved_file_opened_again_gets_its_tab_back() { + let (mut u, a, ..) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-present-tab-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Proto.figcraft").to_string_lossy().to_string(); + u.run("file.saveAs", json!({"path": path})); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.close", json!({"index": 0})); + u.run("file.open", json!({"path": path})); + u.run("proto.present", json!({"start": a})); + // Saved as Proto.figcraft: the file's title follows its name. + assert_eq!(tabs(&u), json!([{"name": "Proto - Page 1", "active": true}])); + u.key(pos2(700.0, 500.0), Key::Escape); + assert!(!u.app.ui.home, "back to its file"); + assert_eq!(tabs(&u), json!([])); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn renaming_the_file_or_the_page_renames_the_tab() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.rename", json!({"name": "Playground"})); + u.run("page.rename", json!({"name": "Calendar"})); + u.frame(vec![]); + assert_eq!(tabs(&u), json!([{"name": "Playground - Calendar", "active": false}])); + u.run("file.close", json!({"index": 0, "force": true})); + u.frame(vec![]); + assert_eq!(tabs(&u)[0]["name"], "Playground - Calendar", "the names stay when the file closes"); +} diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs index a1e4ebe2..73bccaed 100644 --- a/crates/ui-egui/src/present_tests.rs +++ b/crates/ui-egui/src/present_tests.rs @@ -23,11 +23,18 @@ impl Ui { } pub(crate) fn frame(&mut self, events: Vec) { + self.frame_output(events); + } + + /// [`Self::frame`], returning what egui output (the AccessKit tree when it's enabled). + pub(crate) fn frame_output(&mut self, events: Vec) -> egui::FullOutput { self.time += 1.0 / 60.0; let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; let app = &mut self.app; - self.ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear(); + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + out } pub(crate) fn frames(&mut self, n: usize, events: impl Fn() -> Vec) { @@ -175,6 +182,38 @@ fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { assert_eq!(u.scroll_y(), 0.0); } +/// A long top-level frame scrolls on a phone with no overflow setting, as in Figma: the landing +/// page template's Phone frame ("No scrolling", hugging its height) scrolls by the wheel. +#[test] +fn a_top_level_frame_taller_than_the_phone_scrolls_without_an_overflow_setting() { + let mut s = Session::new(); + s.execute("file.newFromTemplate", &json!({"template": "landing-page"})).unwrap(); + s.execute("proto.settings", &json!({"device": "iPhone 16", "responsive": true})).unwrap(); + let doc = s.doc().unwrap(); + let page = doc.first_page().unwrap(); + let phone = doc.top_frames(page).into_iter().find(|f| doc.node(*f).unwrap().name == "Phone").unwrap(); + assert_eq!(doc.node(phone).unwrap().overflow, figcraft_doc::proto::Overflow::None); + let mut u = Ui::new(s); + u.run("proto.preview", json!({"start": phone})); + let range = u.preview()["scrollRange"][1].as_f64().unwrap(); + assert!(range > 1000.0, "the page is far taller than the screen: {range}"); + let c = u.pane().center(); + let wheel = move || { + vec![ + Event::PointerMoved(c), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -60.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }, + ] + }; + u.frames(6, wheel); + u.frames(20, Vec::new); + assert!(u.scroll_y() > 0.0, "{}", u.scroll_y()); +} + #[test] fn full_window_presenter_shares_the_player() { let (mut u, a, ..) = setup(); diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 8fdd5eb0..42647dc0 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -106,14 +106,14 @@ fn presentation_tab_keeps_the_current_screen_when_returning_to_the_editor() { u.button(design_tab, true); u.button(design_tab, false); assert!(u.app.ui.present.is_none()); - assert_eq!(u.app.ui.paused_present.as_ref().map(|p| p.current), Some(b)); + assert_eq!(presenter(&u)["current"], json!(b)); let name_len = u.app.session.doc().unwrap().name.chars().count() as f32; let design_w = (12.0 * 0.6 * name_len + 64.0).clamp(120.0, 220.0); let preview_tab = pos2(8.0 + 44.0 + design_w + 40.0, 20.0); u.button(preview_tab, true); u.button(preview_tab, false); - assert!(u.app.ui.paused_present.is_none()); + assert!(u.app.ui.present.is_some()); assert_eq!(presenter(&u)["current"], json!(b)); } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index afa884a8..6738f300 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -286,11 +286,13 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl if hovered { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } - let col = t.menu_text; + // These menus sit on the theme's popup fill (white in the light theme), not on the dark + // `t.menu` of the menu bar: the theme's text, white only on the green highlight (#972). + let col = if hovered { t.menu_text } else { t.text }; ui.painter().galley(pos2(r.min.x + MENU_PAD, r.center().y - text.size().y / 2.0), text, col); if let Some(k) = keys { let at = pos2(r.max.x - MENU_PAD - k.size().x, r.center().y - k.size().y / 2.0); - ui.painter().galley(at, k, if hovered { t.menu_text } else { t.menu_dim }); + ui.painter().galley(at, k, if hovered { t.menu_text } else { t.text_secondary }); } art(ui.painter(), r, col); resp @@ -879,44 +881,32 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { }); if indep_ok && ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false) { let radii = common(doc, sel, |n| n.corners.radii); - let keys = [ - ("topLeftRadius", tr("Top left radius")), - ("topRightRadius", tr("Top right radius")), - ("bottomRightRadius", tr("Bottom right radius")), - ("bottomLeftRadius", tr("Bottom left radius")), + // `k` indexes `radii` ([top left, top right, bottom right, bottom left]); the fields sit + // where their corners are: top left, top right / bottom left, bottom right. + let corners = [ + ("topLeftRadius", tr("Top left radius"), Icon::CornerTopLeft, 0), + ("topRightRadius", tr("Top right radius"), Icon::CornerTopRight, 1), + ("bottomLeftRadius", tr("Bottom left radius"), Icon::CornerBottomLeft, 3), + ("bottomRightRadius", tr("Bottom right radius"), Icon::CornerBottomRight, 2), ]; - row(ui, 32.0, |ui| { - for (k, (key, name)) in keys.iter().enumerate().take(2) { - let e = crate::variable_ui::number_field( - app, - ui, - &ids, - key, - Id::new(("corner", k)), - Lead::Icon(Icon::CornerRadius, name), - radii.map(|r| r[k]), - "", - col, - ); - app.field(&ids, key, e, |v| json!(v.max(0.0))); - } - }); - row(ui, 32.0, |ui| { - for (k, (key, name)) in keys.iter().enumerate().skip(2) { - let e = crate::variable_ui::number_field( - app, - ui, - &ids, - key, - Id::new(("corner", k)), - Lead::Icon(Icon::CornerRadius, name), - radii.map(|r| r[k]), - "", - col, - ); - app.field(&ids, key, e, |v| json!(v.max(0.0))); - } - }); + for pair in corners.chunks(2) { + row(ui, 32.0, |ui| { + for (key, name, icon, k) in pair { + let e = crate::variable_ui::number_field( + app, + ui, + &ids, + key, + Id::new(("corner", *k)), + Lead::Icon(*icon, name), + radii.map(|r| r[*k]), + "", + col, + ); + app.field(&ids, key, e, |v| json!(v.max(0.0))); + } + }); + } let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round()); widgets::sublabels(ui, tr("Corner smoothing"), None); row(ui, 32.0, |ui| { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c86cfdf1..4d44115a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -97,8 +97,16 @@ fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str { /// `swallowed`: what this frame's Cmd/Ctrl+V chords the shell swallowed ask for ([`swallowed_paste`]). pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowed) { note_platform(ctx); - // Text fields in panels own the keyboard (also the key that ends their editing). + // A save asked for from a field last frame: the field has committed by now. + if let Some(id) = app.ui.after_field.take() { + run_binding(app, id); + } + // Text fields in panels own the keyboard (also the key that ends their editing), except for + // the save shortcuts. if typing || crate::a11y::field_focused(ctx) || app.ui.palette.is_some() { + if app.ui.palette.is_none() { + save_from_field(app, ctx); + } return; } let events: Vec = ctx.input(|i| i.events.clone()); @@ -137,6 +145,33 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowe } } +/// Commands whose shortcut works while a panel field has the keyboard: they don't edit text, and +/// a file that can't be saved from a field looks like a file that can't be saved (#1157). +const FROM_A_FIELD: [&str; 2] = ["file.save", "file.saveAs"]; + +/// Cmd/Ctrl+S (or Shift+Cmd/Ctrl+S) pressed in a panel field: the field lets go of the keyboard, +/// which commits what was typed as clicking away does, and the save runs next frame, after that +/// commit. +fn save_from_field(app: &mut FigApp, ctx: &Context) { + let id = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + Event::Key { key, physical_key, pressed: true, repeat: false, modifiers } if modifiers.command => { + lookup(keymap::Context::Canvas, *key, *physical_key, *modifiers).map(|b| b.id).filter(|id| FROM_A_FIELD.contains(id)) + } + _ => None, + }) + }); + if let Some(id) = id { + ctx.memory_mut(|m| { + if let Some(f) = m.focused() { + m.surrender_focus(f); + } + }); + app.ui.after_field = Some(id); + ctx.request_repaint(); + } +} + /// The command Cmd/Ctrl+V runs with `m` held too: `edit.paste`, `edit.pasteOver` (Shift), /// `edit.pasteProperties` (Alt), or `None` for a chord nothing is bound to. The keymap decides, /// whether the key press arrived (browser) or only a paste event and the release (desktop). @@ -210,6 +245,8 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed { /// Run the command a shortcut is bound to, with the keyboard's own twists (Escape steps out /// before it deselects, Delete ends a pen path, Enter edits the selected text or vector). pub fn run_binding(app: &mut FigApp, id: &str) { + // Dev Mode places and edits nothing: Enter selects children. + let dev = app.ui.right_tab == crate::RightTab::Inspect; match id { "select.none" => { if let Some(ctx) = &app.ui_context @@ -226,10 +263,10 @@ pub fn run_binding(app: &mut FigApp, id: &str) { let _ = app.session.pen_finish(); } // Enter with a shape or frame tool chosen places one in the middle of the view. - "select.children" if app.session.tool.tool.draws_shape() => { + "select.children" if !dev && app.session.tool.tool.draws_shape() => { app.run("tool.place", json!({})); } - "select.children" if enter_text(app) => {} + "select.children" if !dev && enter_text(app) => {} _ => crate::menus::invoke(app, id), } } @@ -291,6 +328,8 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if (dx, dy) != (0.0, 0.0) { if app.session.selection().is_empty() { app.run("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift})); + } else if crate::menus::dev_mode_refuses(app, "transform.nudge") { + // Dev Mode: said so, nothing moves. } else if m.command { // Cmd/Ctrl+arrows resize: right/down grow, left/up shrink. app.run("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift})); @@ -387,6 +426,9 @@ pub fn opacity_digit(app: &mut FigApp, key: Key, now: f64) -> bool { if !app.session.prefs.number_keys_opacity || app.session.selection().is_empty() { return false; } + if crate::menus::dev_mode_refuses(app, "object.opacityKeys") { + return true; + } let first = app.ui.opacity_digit.take().filter(|(_, t)| now - t <= OPACITY_CHAIN_SECS).map(|(d, _)| d); let digits = match first { Some(f) => format!("{f}{d}"), @@ -413,6 +455,15 @@ fn enter_text(app: &mut FigApp) -> bool { return false; } let Some(id) = sel.first().copied() else { return false }; + // A board object (a sticky, a shape with text, a label): type in its text. + match figcraft_engine::board::edit_text(&mut app.session, id) { + Ok(true) => return true, + Ok(false) => {} + Err(e) => { + app.toast(e.to_string()); + return true; + } + } let kind = app.session.doc().ok().and_then(|d| d.node(id)).map(|n| (matches!(n.kind, figcraft_doc::NodeKind::Text(_)), n.kind.is_shape())); match kind { Some((true, _)) => { diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 29c9c5a9..22713000 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -909,7 +909,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { // Clicks in the colour picker editing this picker's style, or in a menu the editor opened, // keep it open. let picker_rect = - app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(Id::new("color_picker")))); + app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(crate::colorpicker::window_id()))); // So do clicks in the variable picker binding one of its fields (Escape closes that first). let variables = crate::variable_ui::picker_rect(ctx); let in_popup = egui::Popup::is_any_open(ctx) || variables.is_some(); diff --git a/crates/ui-egui/src/style_picker_tests.rs b/crates/ui-egui/src/style_picker_tests.rs index becc42ce..ad2c2971 100644 --- a/crates/ui-egui/src/style_picker_tests.rs +++ b/crates/ui-egui/src/style_picker_tests.rs @@ -164,6 +164,34 @@ fn chars_linked_to_several_styles_show_mixed() { assert_eq!(text_link(&app, &doc, &[t]), None); } +#[test] +fn a_click_in_the_colour_picker_keeps_the_style_editor_open() { + use crate::layers_drag_tests::Ui; + let mut s = Session::new(); + s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + let mut u = Ui::new(s); + open_for_selection(&mut u.app, StyleSlot::Fill, Some("Brand")); + for _ in 0..3 { + u.frame(vec![]); + } + let id = u.app.session.doc().unwrap().style("Brand").unwrap().id.clone(); + u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Style { id, index: 0 }); + for _ in 0..3 { + u.frame(vec![]); + } + let cp = u.ctx.memory(|m| m.area_rect(crate::colorpicker::window_id())).expect("colour picker shows"); + let p = cp.center(); + u.button(p, true); + u.button(p, false); + u.frame(vec![]); + assert!(u.app.ui.style_picker.is_some(), "the style editor stays open while the colour picker is used"); + assert!( + matches!(u.app.ui.picker, Some(crate::colorpicker::PickerTarget::Style { .. })), + "the colour picker stays open too: {:?}", + u.app.ui.picker + ); +} + #[test] fn local_style_drops_move_into_the_folder_above() { let st = |id: &str, name: &str| Style { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index c10edcfd..521fd05c 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -3,11 +3,13 @@ //! window buttons; the window's edges resize it. use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use figcraft_doc::NodeId; use serde_json::json; use crate::a11y::Tip; use crate::i18n::{tr, trf}; use crate::icons::{self, Icon}; +use crate::present::{PresentTab, Presenter}; use crate::theme::{Tokens, regular}; use crate::{FigApp, TitleBar}; @@ -113,9 +115,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon); if hresp.clicked() { - if let Some(p) = app.ui.present.take() { - app.ui.paused_present = Some(p); - } + pause_present(app); app.ui.home = true; } x += 44.0; @@ -141,8 +141,13 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }) .collect(); // Leave the new-tab button, theme toggle, window controls and a draggable gap free. - let present_w = if app.ui.present.is_some() || app.ui.paused_present.is_some() { 128.0 } else { 0.0 }; - let available = (rect.max.x - buttons_w - 70.0 - x - 34.0 - present_w).max(0.0); + let room = (rect.max.x - buttons_w - 70.0 - x - 34.0).max(0.0); + let present_pref: Vec = app.ui.presents.iter().map(present_tab_width).collect(); + let present_on = app.ui.present.as_ref().and(app.ui.present_active); + let active_present = present_on.and_then(|id| app.ui.presents.iter().position(|p| p.id == id)); + let present_layout = tab_layout(&present_pref, present_room(room, &present_pref, !document_tabs.is_empty()), active_present); + let present_used = present_layout.widths.iter().sum::() + if present_layout.overflow { OVERFLOW_WIDTH } else { 0.0 }; + let available = (room - present_used).max(0.0); let active_tab = document_tabs.iter().position(|&i| i == app.session.active); let layout = tab_layout(&preferred, available, active_tab); let mut tab_rects = Vec::with_capacity(layout.widths.len()); @@ -212,7 +217,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { icons::paint( &painter, Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)), - Icon::Logo, + icons::file_mark(d.doc.kind), crate::theme::ACCENT.linear_multiply(progress), ); // The unsaved-changes dot follows the name on screen, whichever way the name reads. @@ -282,41 +287,45 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); x += OVERFLOW_WIDTH; } - // Presentations live in their own tab. Switching back to the design keeps the player + // Presentations live in their own tabs. Switching back to the design keeps the player // state (current screen, navigation history and scroll position) for the return trip. - if app.ui.present.is_some() || app.ui.paused_present.is_some() { - let w = 128.0; - let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); - let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); - let close_r = Rect::from_min_max(body_r.right_top(), r.max); - let resp = ui.interact(body_r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present")); - let close_resp = ui.interact(close_r, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview")); - let active = app.ui.present.is_some(); - crate::a11y::tab(&resp, tr("Present"), active); - if active { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); - } else if resp.hovered() || close_resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + let mut to_present = None; + let mut close_present_tab = None; + for (tab, &w) in app.ui.presents.iter().skip(present_layout.first).zip(&present_layout.widths) { + let active = present_on == Some(tab.id); + let (clicked, closed) = present_tab(ui, &t, tab, Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)), active); + if clicked && !active { + to_present = Some(tab.id); } - icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); - ui.painter().text(pos2(x + 34.0, r.center().y), Align2::LEFT_CENTER, tr("Present"), regular(12.0), t.text); - let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0)); - if resp.hovered() || close_resp.hovered() || active { - if close_resp.hovered() { - ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); - } - icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); - } - if close_resp.clicked() { - app.ui.present = None; - app.ui.paused_present = None; - app.ui.present_uid = None; - } else if resp.clicked() && !active { - resume_presentation(app); + if closed { + close_present_tab = Some(tab.id); } x += w; ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); } + if present_layout.overflow { + let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT)); + let resp = ui.interact(r, ui.id().with("present_overflow"), Sense::click()).tip(tr("Open presentations")); + crate::a11y::menu_button(&resp, tr("Open presentations"), crate::a11y::menu_open(&resp)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon); + crate::right::menu_popup(&resp).show(|ui| { + egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| { + for tab in &app.ui.presents { + let on = present_on == Some(tab.id); + if crate::right::menu_check(ui, on, &present_tab_name(tab), "") { + if !on { + to_present = Some(tab.id); + } + ui.close(); + } + } + }); + }); + x += OVERFLOW_WIDTH; + } // New tab. let nr = Rect::from_center_size(pos2(x + 20.0, rect.center().y), vec2(28.0, 28.0)); let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).tip(crate::shortcuts::tip(tr("New design file"), "file.new")); @@ -325,9 +334,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), nr.shrink(7.0), Icon::Plus, t.icon); if nresp.clicked() { - if let Some(p) = app.ui.present.take() { - app.ui.paused_present = Some(p); - } + pause_present(app); app.run("file.new", json!({})); app.ui.home = false; } @@ -355,12 +362,15 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } } if let Some(i) = switch { - if let Some(p) = app.ui.present.take() { - app.ui.paused_present = Some(p); - } + pause_present(app); app.run("doc.switch", json!({"index": i})); app.ui.home = false; } + if let Some(id) = close_present_tab { + close_present(app, id); + } else if let Some(id) = to_present { + show_present(app, id); + } if let Some((i, at)) = detach { // Where the pointer let go, on the screen. let at = at.and_then(|p| ui.input(|inp| inp.viewport().inner_rect).map(|r| r.min + p.to_vec2() - vec2(60.0, HEIGHT / 2.0))); @@ -388,11 +398,6 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } else { app.run("file.close", json!({"index": i})); } - if app.ui.present_uid.is_some_and(|uid| !app.session.docs.iter().any(|d| d.uid == uid)) { - app.ui.present = None; - app.ui.paused_present = None; - app.ui.present_uid = None; - } } if !opening.is_empty() { ui.ctx().request_repaint(); @@ -400,16 +405,164 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); } -fn resume_presentation(app: &mut FigApp) { - let Some(uid) = app.ui.present_uid else { return }; - let Some(index) = app.session.docs.iter().position(|d| d.uid == uid) else { - app.ui.paused_present = None; - app.ui.present_uid = None; +/// One presentation tab in `r`: whether it was clicked and whether it was closed (its X or a +/// middle click). +fn present_tab(ui: &mut Ui, t: &Tokens, tab: &PresentTab, r: Rect, active: bool) -> (bool, bool) { + let label = present_tab_name(tab); + let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); + let close_r = Rect::from_min_max(body_r.right_top(), r.max); + let resp = ui.interact(body_r, ui.id().with(("presentation_tab", tab.id)), Sense::click()).tip(tr("Present")); + let cresp = ui.interact(close_r, ui.id().with(("presentation_close", tab.id)), Sense::click()).tip(tr("Close preview")); + crate::a11y::tab(&resp, &label, active); + let hovered = resp.hovered() || cresp.hovered(); + if active { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); + } else if hovered { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 34.0, r.center().y), &label, regular(12.0), t.text, r.width() - 62.0); + if hovered || active { + let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0)); + if cresp.hovered() { + ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); + } + icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); + } + let closed = cresp.clicked() || resp.middle_clicked() || cresp.middle_clicked(); + (resp.clicked(), closed) +} + +/// The part of the strip's free `room` the presentation tabs may use: all they want, but at most +/// half of it while file tabs share the strip, so they shrink or overflow before covering controls. +fn present_room(room: f32, preferred: &[f32], files: bool) -> f32 { + preferred.iter().sum::().min(if files { room / 2.0 } else { room }) +} + +fn present_tab_width(tab: &PresentTab) -> f32 { + (regular(12.0).size * 0.6 * present_tab_name(tab).chars().count() as f32 + 64.0).clamp(140.0, 260.0) +} + +/// "file - page", or the file alone when the page has no name. +fn present_tab_name(tab: &PresentTab) -> String { + if tab.page_name.is_empty() { tab.file.clone() } else { format!("{} - {}", tab.file, tab.page_name) } +} + +/// The presentation tabs for `ui.inspect`: each one's name and whether it is on screen. +pub fn present_tabs_inspect(app: &FigApp) -> serde_json::Value { + let on_screen = |id| app.ui.present.is_some() && app.ui.present_active == Some(id); + app.ui.presents.iter().map(|t| json!({"name": present_tab_name(t), "active": on_screen(t.id)})).collect() +} + +/// The presenter on screen, else the paused one of the active presentation tab. +pub fn shown_or_paused(app: &FigApp) -> Option<&Presenter> { + app.ui.present.as_ref().or_else(|| { + let id = app.ui.present_active?; + app.ui.presents.iter().find(|t| t.id == id)?.pres.as_ref() + }) +} + +/// The index of the open file with `uid`, when a file tab shows it. +fn owner(app: &FigApp, uid: u64) -> Option { + app.session.docs.iter().position(|d| d.uid == uid && app.shows(uid)) +} + +/// The presentation on screen plays the file in front (or no tab holds it). Only then may it read +/// and change that file. +pub fn present_owner_active(app: &FigApp) -> bool { + let Some(tab) = app.ui.present_active.and_then(|id| app.ui.presents.iter().find(|t| t.id == id)) else { return true }; + owner(app, tab.doc_uid).is_some_and(|i| i == app.session.active) +} + +/// Puts the presenter on screen back into its tab, where it keeps its state. +fn pause_present(app: &mut FigApp) { + let Some(p) = app.ui.present.take() else { return }; + let id = app.ui.present_active; + if let Some(t) = app.ui.presents.iter_mut().find(|t| Some(t.id) == id) { + t.pres = Some(p); + } +} + +/// Presents from `start`: the tab of its page in this file shows again (from `start` when that +/// changed), else a new tab opens. +pub fn open_present(app: &mut FigApp, start: NodeId) { + let Ok(ds) = app.session.ds() else { return }; + let (uid, path) = (ds.uid, ds.path.clone()); + let page = ds.doc.page_of(start).unwrap_or(ds.page); + let file = ds.doc.name.clone(); + let page_name = ds.doc.node(page).map(|n| n.name.clone()).unwrap_or_default(); + let mut pres = Presenter::new(start); + pres.view = app.ui.present_view; + let same = |t: &&PresentTab| t.page == page && (t.doc_uid == uid || (path.is_some() && t.path == path)); + if let Some(id) = app.ui.presents.iter().find(same).map(|t| t.id) { + show_present(app, id); + if app.ui.present.as_ref().is_some_and(|p| p.start() != start) { + app.ui.present = Some(pres); + } return; - }; - app.run("doc.switch", json!({"index": index})); + } + pause_present(app); + let id = app.ui.present_next; + app.ui.present_next += 1; + app.ui.presents.push(PresentTab { id, pres: None, doc_uid: uid, path, page, file, page_name }); + app.ui.present = Some(pres); + app.ui.present_active = Some(id); app.ui.home = false; - app.ui.present = app.ui.paused_present.take(); +} + +/// Brings tab `id` to the front and its file too, when that file is open. +fn show_present(app: &mut FigApp, id: u64) { + app.ui.home = false; + if app.ui.present.is_some() && app.ui.present_active == Some(id) { + return; + } + pause_present(app); + let Some(tab) = app.ui.presents.iter_mut().find(|t| t.id == id) else { return }; + let Some(p) = tab.pres.take() else { return }; + let uid = tab.doc_uid; + if let Some(i) = owner(app, uid).filter(|i| *i != app.session.active) { + app.run("doc.switch", json!({"index": i})); + } + app.ui.present = Some(p); + app.ui.present_active = Some(id); +} + +/// Closes tab `id`. If it was on screen, its file comes to the front, or Home when it is closed. +pub fn close_present(app: &mut FigApp, id: u64) { + let Some(k) = app.ui.presents.iter().position(|t| t.id == id) else { return }; + let tab = app.ui.presents.remove(k); + if app.ui.present_active != Some(id) { + return; + } + app.ui.present_active = None; + if app.ui.present.take().is_none() { + return; + } + match owner(app, tab.doc_uid) { + Some(i) => { + if i != app.session.active { + app.run("doc.switch", json!({"index": i})); + } + app.ui.home = false; + } + None => app.ui.home = true, + } +} + +/// A file that opens again has a new uid. Find it by its path to give its tabs back. +pub fn relink_presents(app: &mut FigApp) { + let docs = &app.session.docs; + for t in &mut app.ui.presents { + let found = docs.iter().find(|d| d.uid == t.doc_uid).or_else(|| docs.iter().find(|d| t.path.is_some() && d.path == t.path)); + if let Some(d) = found { + t.doc_uid = d.uid; + t.path = d.path.clone(); + t.file = d.doc.name.clone(); + if let Some(n) = d.doc.node(t.page) { + t.page_name = n.name.clone(); + } + } + } } /// What the tab of document `uid` says about its live session: others get in (`true`: a session @@ -493,9 +646,22 @@ pub fn resize_edges(ctx: &egui::Context) { #[cfg(test)] mod layout_tests { - use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, tab_drop_position, tab_layout}; + use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, present_room, tab_drop_position, tab_layout}; use egui::{Rect, pos2}; + #[test] + fn many_presentation_tabs_stay_inside_their_share_of_the_strip() { + let preferred = vec![260.0; 8]; + let room = 900.0; + let layout = tab_layout(&preferred, present_room(room, &preferred, true), Some(7)); + assert!(layout.overflow); + assert!(layout.first <= 7 && 7 < layout.first + layout.widths.len()); + assert!(layout.widths.iter().all(|&width| width >= MIN_TAB_WIDTH)); + assert!(layout.widths.iter().sum::() + OVERFLOW_WIDTH <= room / 2.0 + 0.01); + assert_eq!(present_room(room, &preferred, false), room); + assert_eq!(present_room(room, &[200.0], true), 200.0); + } + #[test] fn tabs_shrink_to_fit_before_overflowing() { let preferred = vec![120.0; 10]; diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index d92cdf06..b95575b8 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -28,23 +28,35 @@ struct Group { /// The flyout button's name for screen readers. name: &'static str, tools: &'static [(ToolId, Icon)], + /// What a one-tool group's flyout offers instead of tools (boards: colours, shapes, stamps). + options: Options, +} + +/// The choices a board tool's flyout shows. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Options { + None, + Colors, + Shapes, + Stamps, +} + +impl Group { + const fn tools(key: &'static str, name: &'static str, tools: &'static [(ToolId, Icon)]) -> Group { + Group { key, name, tools, options: Options::None } + } + fn has_flyout(&self) -> bool { + self.tools.len() > 1 || self.options != Options::None + } } const GROUPS: &[Group] = &[ - Group { - key: "move", - name: tr_noop("Move tools"), - tools: &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand), (ToolId::Scale, Icon::Scale)], - }, - Group { - key: "frame", - name: tr_noop("Frame tools"), - tools: &[(ToolId::Frame, Icon::Frame), (ToolId::Section, Icon::Section), (ToolId::Slice, Icon::Slice)], - }, - Group { - key: "shape", - name: tr_noop("Shape tools"), - tools: &[ + Group::tools("move", tr_noop("Move tools"), &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand), (ToolId::Scale, Icon::Scale)]), + Group::tools("frame", tr_noop("Frame tools"), &[(ToolId::Frame, Icon::Frame), (ToolId::Section, Icon::Section), (ToolId::Slice, Icon::Slice)]), + Group::tools( + "shape", + tr_noop("Shape tools"), + &[ (ToolId::Rectangle, Icon::Rectangle), (ToolId::Line, Icon::Line), (ToolId::Arrow, Icon::Arrow), @@ -52,14 +64,31 @@ const GROUPS: &[Group] = &[ (ToolId::Polygon, Icon::Polygon), (ToolId::Star, Icon::Star), ], - }, - Group { - key: "pen", - name: tr_noop("Drawing tools"), - tools: &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)], - }, - Group { key: "text", name: tr_noop("Text"), tools: &[(ToolId::Text, Icon::Text)] }, - Group { key: "comment", name: tr_noop("Comment"), tools: &[(ToolId::Comment, Icon::Comment)] }, + ), + Group::tools( + "pen", + tr_noop("Drawing tools"), + &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)], + ), + Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text)]), + Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]), +]; + +/// A board's toolbar: moving around, ink, stickies, shapes, connectors, text, sections, stamps. +const BOARD_GROUPS: &[Group] = &[ + Group::tools("bmove", tr_noop("Move tools"), &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)]), + Group::tools( + "ink", + tr_noop("Drawing tools"), + &[(ToolId::Marker, Icon::Marker), (ToolId::Highlighter, Icon::Highlighter), (ToolId::Eraser, Icon::Eraser)], + ), + Group { key: "sticky", name: tr_noop("Sticky note colors"), tools: &[(ToolId::Sticky, Icon::Board)], options: Options::Colors }, + Group { key: "bshape", name: tr_noop("Shapes"), tools: &[(ToolId::BoardShape, Icon::Shapes)], options: Options::Shapes }, + Group::tools("connector", tr_noop("Connector"), &[(ToolId::Connector, Icon::Connector)]), + Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text)]), + Group::tools("section", tr_noop("Section"), &[(ToolId::Section, Icon::Section)]), + Group { key: "stamp", name: tr_noop("Stamps"), tools: &[(ToolId::Stamp, Icon::Stamp)], options: Options::Stamps }, + Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]), ]; fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId { @@ -85,73 +114,120 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { builder_toolbar(app, ui); return; } - main_toolbar(app, ui, canvas, &t); + let board = app.session.doc().is_ok_and(figcraft_engine::board::is_board); + main_toolbar(app, ui, canvas, &t, board); } -/// The main toolbar pill's width: the Actions palette matches it so the two read as one bar. -pub(crate) fn main_width() -> f32 { - let (cell, gap) = (32.0, 4.0); - GROUPS.len() as f32 * (cell + gap) + gap * 10.0 + 2.0 * (cell + gap) + 16.0 + 4.0 * 34.0 + 24.0 +/// The main toolbar pill's width for the active file: the Actions palette matches it so the two +/// read as one bar. +pub(crate) fn bar_width(app: &FigApp) -> f32 { + let board = app.session.doc().is_ok_and(figcraft_engine::board::is_board); + main_width(if board { BOARD_GROUPS } else { GROUPS }, board) +} + +/// The main toolbar pill's width: the tool groups with their flyout carets, Resources and +/// Actions, and the mode switcher (boards: the Design panel's button instead). +fn main_width(groups: &[Group], board: bool) -> f32 { + let (cell, gap) = (TOOL_CELL, 4.0); + let carets = groups.iter().filter(|g| g.has_flyout()).count() as f32 * 10.0; + let end = if board { cell + 16.0 } else { 4.0 * 34.0 + 24.0 }; + groups.len() as f32 * (cell + gap) + carets + 2.0 * (cell + gap) + 16.0 + end } /// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the -/// Draw · Design · Prototype · Inspect mode switcher. -fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { - let (cell, gap) = (32.0, 4.0); - let width = main_width(); - let rect = pill(ui, &mut app.ui.nav, canvas, width, t); - let current = app.session.tool.tool; - let mut x = rect.min.x + 8.0; +/// Draw · Design · Prototype · Inspect mode switcher (on a board, the Design panel's button). +fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: bool) { + let groups = if board { BOARD_GROUPS } else { GROUPS }; + let rect = pill(ui, &mut app.ui.nav, canvas, main_width(groups, board), t); let cy = rect.center().y; - for g in GROUPS { - let in_group = g.tools.iter().any(|(tid, _)| *tid == current); - let shown = if in_group { current } else { remembered(ui, g.key, g.tools.first().map(|x| x.0).unwrap_or(ToolId::Move)) }; - let icon = g.tools.iter().find(|(tid, _)| *tid == shown).map(|(_, i)| *i).unwrap_or(Icon::Move); - let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); - let resp = tool_cell(ui, t, r, ("tool", g.key), icon, in_group, &format!("{} {}", tr(shown.label()), pretty_shortcut(shown.shortcut()))); - if in_group { - a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id); + let mut x = rect.min.x + 8.0; + for g in groups { + x = group_cell(app, ui, g, pos2(x, cy), rect.min.y, t); + } + x = side_buttons(app, ui, x, cy, t); + divider(ui, t, x, cy); + x += 8.0; + if board { + // Boards have no prototypes or Dev Mode; the Design panel is hidden until asked for. + let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); + let tip = crate::shortcuts::tip(tr("Design panel"), "view.design"); + if tool_cell(ui, t, r, "board_panel", Icon::Panels, app.ui.board_panel, tip).clicked() { + app.run("view.design", json!({})); } - if resp.clicked() { - // Enter (a keyboard click) on the shape or frame tool already chosen places one; the - // canvas takes the keyboard. - if in_group && shown.draws_shape() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - app.run("tool.place", json!({})); - app.ui.nav.go(Region::Canvas); - } else { - app.run("tool.set", json!({"tool": shown.id()})); - } - } - x += cell; - // Flyout chevron. - if g.tools.len() > 1 { - let cr = Rect::from_center_size(pos2(x + 5.0, cy), vec2(10.0, cell)); - let cresp = ui.interact(cr, ui.id().with(("flyout", g.key)), Sense::click()); - let open = app.ui.tool_flyout == Some(g.key); - a11y::menu_button(&cresp, tr(g.name), open); - if open { - a11y::popup(&mut app.ui.nav, cresp.id, flyout_id(g.key)); - } - if cresp.hovered() { - ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover); - } - icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(CARET, CARET)), Icon::Chevron, t.icon_secondary); - if cresp.clicked() { - app.ui.tool_flyout = if app.ui.tool_flyout == Some(g.key) { None } else { Some(g.key) }; - } - x += 10.0; - } - x += gap; - if app.ui.tool_flyout == Some(g.key) { - flyout(app, ui, g, pos2(r.min.x, rect.min.y - 8.0)); + } else { + mode_switcher(app, ui, x, cy, t); + } + help_button(app, ui, canvas, t); +} + +/// A toolbar button's size. +const TOOL_CELL: f32 = 32.0; + +/// Tool group `g` at `at` (its left edge, the pill's middle): the shown tool's button, its flyout +/// caret and, while open, its flyout above the pill (whose top is `top`). Where the next starts. +fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32, t: &Tokens) -> f32 { + let current = app.session.tool.tool; + let in_group = g.tools.iter().any(|(tid, _)| *tid == current); + let shown = if in_group { current } else { remembered(ui, g.key, g.tools.first().map(|x| x.0).unwrap_or(ToolId::Move)) }; + let icon = g.tools.iter().find(|(tid, _)| *tid == shown).map(|(_, i)| *i).unwrap_or(Icon::Move); + let r = Rect::from_center_size(pos2(at.x + TOOL_CELL / 2.0, at.y), vec2(TOOL_CELL, TOOL_CELL)); + let resp = tool_cell(ui, t, r, ("tool", g.key), icon, in_group, &format!("{} {}", tr(shown.label()), pretty_shortcut(shown.shortcut()))); + if in_group { + a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id); + } + if resp.clicked() { + // Enter (a keyboard click) on the shape or frame tool already chosen places one; the + // canvas takes the keyboard. + // In Dev Mode the tool goes through `choose`, which returns to the design first. + if in_group && shown.draws_shape() && app.ui.right_tab != crate::RightTab::Inspect && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + app.run("tool.place", json!({})); + app.ui.nav.go(Region::Canvas); + } else { + choose(app, shown); } } - // Resources (components, plugins) opens the Assets panel; Actions palette. + let mut x = at.x + TOOL_CELL; + if g.has_flyout() { + flyout_caret(app, ui, g, pos2(x, at.y), t); + x += 10.0; + } + if app.ui.tool_flyout == Some(g.key) { + if g.options == Options::None { + flyout(app, ui, g, pos2(r.min.x, top - 8.0)); + } else { + options_flyout(app, ui, g, pos2(r.min.x, top - 8.0)); + } + } + x + 4.0 +} + +/// The caret after group `g`'s button (at `at`, its left edge and middle): opens or closes its +/// flyout. +fn flyout_caret(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, t: &Tokens) { + let cr = Rect::from_center_size(pos2(at.x + 5.0, at.y), vec2(10.0, TOOL_CELL)); + let cresp = ui.interact(cr, ui.id().with(("flyout", g.key)), Sense::click()); + let open = app.ui.tool_flyout == Some(g.key); + a11y::menu_button(&cresp, tr(g.name), open); + if open { + a11y::popup(&mut app.ui.nav, cresp.id, flyout_id(g.key)); + } + if cresp.hovered() { + ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(CARET, CARET)), Icon::Chevron, t.icon_secondary); + if cresp.clicked() { + app.ui.tool_flyout = if open { None } else { Some(g.key) }; + } +} + +/// Resources (components, plugins: the Assets panel) and the Actions palette's button, from `x`. +/// Where the next part starts. +fn side_buttons(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { for (icon, tip, id) in [ (Icon::Assets, crate::shortcuts::key_tip(tr("Resources"), "Shift+I"), "resources"), (Icon::Actions, crate::shortcuts::tip(tr("Actions"), "app.actions"), "actions"), ] { - let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); + let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); let resp = ui.interact(r, ui.id().with(id), Sense::click()).tip(tip); if id == "actions" { app.ui.actions_rect = Some(r); @@ -171,11 +247,13 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { app.run("view.assets", json!({})); } } - x += cell + 4.0; + x += TOOL_CELL + 4.0; } - divider(ui, t, x, cy); - x += 8.0; - // Mode switcher: Draw · Design · Prototype · Inspect (code). + x +} + +/// The mode switcher from `x`: Draw · Design · Prototype · Inspect (code). +fn mode_switcher(app: &mut FigApp, ui: &mut Ui, x: f32, cy: f32, t: &Tokens) { let modes = [(Icon::Pencil, tr("Draw")), (Icon::Frame, tr("Design")), (Icon::Prototype, tr("Prototype")), (Icon::Code, tr("Inspect"))]; let active = match app.ui.right_tab { crate::RightTab::Design if app.session.tool.tool == ToolId::Pencil => 0, @@ -197,23 +275,31 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary }); if resp.clicked() { - match i { - 0 => { - app.ui.right_tab = crate::RightTab::Design; - app.run("tool.set", json!({"tool": "pencil"})); - } - 1 => { - app.ui.right_tab = crate::RightTab::Design; - if app.session.tool.tool == ToolId::Pencil { - app.run("tool.set", json!({"tool": "move"})); - } - } - 2 => app.ui.right_tab = crate::RightTab::Prototype, - _ => app.ui.right_tab = crate::RightTab::Inspect, - } + pick_mode(app, i); } } - // Floating help button at the bottom right of the canvas. +} + +/// Mode `i` of the switcher chosen. +fn pick_mode(app: &mut FigApp, i: usize) { + match i { + 0 => { + app.ui.right_tab = crate::RightTab::Design; + app.run("tool.set", json!({"tool": "pencil"})); + } + 1 => { + app.ui.right_tab = crate::RightTab::Design; + if app.session.tool.tool == ToolId::Pencil { + app.run("tool.set", json!({"tool": "move"})); + } + } + 2 => app.ui.right_tab = crate::RightTab::Prototype, + _ => app.ui.right_tab = crate::RightTab::Inspect, + } +} + +/// The floating help button at the bottom right of the canvas. +fn help_button(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { let hr = Rect::from_center_size(pos2(canvas.max.x - 32.0, canvas.max.y - 32.0), vec2(32.0, 32.0)); let hresp = ui.interact(hr, ui.id().with("help"), Sense::click()).tip(tr("Help and resources")); ui.painter().circle_filled(hr.center() + vec2(0.0, 1.0), 17.0, Color32::from_black_alpha(20)); @@ -380,7 +466,7 @@ fn pill(ui: &Ui, nav: &mut Nav, canvas: Rect, width: f32, t: &Tokens) -> Rect { } /// A square tool button: accent when active, tinted on hover; `tip` is its tooltip and name. -fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Icon, active: bool, tip: &str) -> egui::Response { +pub(crate) fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Icon, active: bool, tip: &str) -> egui::Response { let resp = ui.interact(r, ui.id().with(id), Sense::click()).on_hover_ui(|ui| { ui.label(tip); }); @@ -414,6 +500,41 @@ fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool { resp.clicked() } +/// A board tool's flyout: the colours, shapes or stamps it places, in a row. A pick chooses the +/// tool with it. +fn options_flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { + let t = Tokens::get(ui.ctx()); + let Some(&(tool, _)) = g.tools.first() else { return }; + let items = crate::board_bar::choices(g.options, &app.session.tool.board); + let (cell, pad) = (32.0, 8.0); + let size = vec2(items.len() as f32 * (cell + 4.0) - 4.0 + 2.0 * pad, cell + 2.0 * pad); + let mut chosen = None; + egui::Area::new(flyout_id(g.key)).fixed_pos(pos2(bottom_left.x, bottom_left.y - size.y)).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + ui.painter().rect_filled(rect, CornerRadius::same(13), t.menu); + for (i, c) in items.iter().enumerate() { + let r = Rect::from_min_size(pos2(rect.min.x + pad + i as f32 * (cell + 4.0), rect.min.y + pad), vec2(cell, cell)); + let resp = ui.interact(r, ui.id().with(("opt", g.key, i)), Sense::click()).on_hover_text(tr(c.label)); + a11y::menu_item(&resp, tr(c.label), "", Some(c.on)); + a11y::own_focus(&resp); + if c.on || resp.hovered() || resp.has_focus() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); + } + crate::board_bar::paint_choice(ui.painter(), r.shrink(5.0), &c.mark, t.menu_text); + if resp.clicked() { + chosen = Some(c.params.clone()); + } + } + }); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.tool_flyout = None; + } + if let Some(params) = chosen { + app.run(&format!("tool.{}", tool.id()), params); + app.ui.tool_flyout = None; + } +} + fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { let t = Tokens::get(ui.ctx()); let item_h = 32.0; @@ -452,11 +573,20 @@ fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { if let Some(i) = ToolId::ALL.iter().position(|x| *x == tid) { ui.data_mut(|d| d.insert_temp(egui::Id::new(("tb_last", g.key)), i as u8)); } - app.run("tool.set", json!({"tool": tid.id()})); + choose(app, tid); app.ui.tool_flyout = None; } } +/// Choose `tool` from the toolbar. Dev Mode only inspects: a tool that edits leaves it for the +/// design. +fn choose(app: &mut FigApp, tool: ToolId) { + if app.ui.right_tab == crate::RightTab::Inspect && !matches!(tool, ToolId::Move | ToolId::Hand | ToolId::Comment) { + app.ui.right_tab = crate::RightTab::Design; + } + app.run("tool.set", json!({"tool": tool.id()})); +} + /// A tool group's flyout (an egui area). fn flyout_id(key: &str) -> egui::Id { egui::Id::new(("flyout_area", key)) diff --git a/crates/ui-egui/src/type_settings_tests.rs b/crates/ui-egui/src/type_settings_tests.rs new file mode 100644 index 00000000..675abfa0 --- /dev/null +++ b/crates/ui-egui/src/type_settings_tests.rs @@ -0,0 +1,29 @@ +//! Regression for #1029: a Type settings row whose label is wider than its column (French "Retrait +//! de paragraphe") stays inside the popover, which otherwise widened a little every frame. +use egui::{Area, Id, Pos2, RawInput, Rect, vec2}; + +#[test] +fn a_long_label_does_not_widen_the_type_settings_popover() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, false, 0); + let id = Id::new("ty_row_test"); + let mut widths = Vec::new(); + for i in 0..30 { + let raw = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0))), time: Some(i as f64 / 60.0), ..Default::default() }; + ctx.run_ui(raw, |ui| { + // As `egui::Popup::width` shows it: a default width the area grows past to fit. + Area::new(id).fixed_pos(Pos2::ZERO).default_width(264.0).show(ui.ctx(), |ui| { + for label in ["List spacing", "Retrait de paragraphe, plus long que la colonne"] { + crate::typography::field_row(ui, label, |ui, fw| ui.allocate_exact_size(vec2(fw, 20.0), egui::Sense::hover())); + } + }); + }) + .textures_delta + .clear(); + widths.push(ctx.memory(|m| m.area_rect(id)).expect("the area is shown").width()); + } + let (first, last) = (widths[1], widths[widths.len() - 1]); + assert!((last - first).abs() < 0.01, "the row widens the popover every frame: {first} → {last}"); + assert!((last - 264.0).abs() < 0.01, "{last}"); +} diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 06185d0c..eefe7c9b 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -468,17 +468,15 @@ fn type_settings( // Indent applies to start-aligned text only; elsewhere the field stays visible but off. let start_aligned = common(doc, ids, |n| n.kind.text().map(|t| t.align_h != TextAlignH::Center && t.align_h != TextAlignH::Justified)).flatten(); - ui.horizontal(|ui| { - ui.add_enabled_ui(start_aligned != Some(false), |ui| { - ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(tr("Paragraph indent")).font(regular(11.0)).color(t.text))); - let field = widgets::number_field(ui, Id::new("ty_indent"), Lead::None, indent, "", w - 118.0, 1.0); - if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = field { - app.set_props(ids, json!({"paragraphIndent": v.clamp(0.0, 10_000.0)})); - } - }) - .response - .on_disabled_hover_text(tr("Paragraph indent applies to left-aligned text")); - }); + ui.add_enabled_ui(start_aligned != Some(false), |ui| { + let field = + field_row(ui, tr("Paragraph indent"), |ui, fw| widgets::number_field(ui, Id::new("ty_indent"), Lead::None, indent, "", fw, 1.0)); + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = field { + app.set_props(ids, json!({"paragraphIndent": v.clamp(0.0, 10_000.0)})); + } + }) + .response + .on_disabled_hover_text(tr("Paragraph indent applies to left-aligned text")); ui.add_space(4.0); for (key, label) in [("hangingPunctuation", tr("Hanging punctuation")), ("hangingList", tr("Hanging list"))] { let all_on = ids.iter().all(|i| { @@ -508,16 +506,13 @@ fn type_settings( if resize == Some(TextAutoResize::Truncate) { let max = common(doc, ids, |n| n.kind.text().map(|t| t.max_lines)).flatten(); ui.add_space(4.0); - ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(tr("Max lines")).font(regular(11.0)).color(t.text))); - let shown = max.map(f64::from).filter(|m| *m > 0.0); - let empty = if max.is_some() { tr("Auto") } else { tr(MIXED) }; - if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = - widgets::number_field_or(ui, Id::new("ty_maxlines"), shown, empty, w - 118.0, 1.0) - { - app.run("text.maxLines", json!({"ids": ids, "lines": v.round().clamp(0.0, 10_000.0)})); - } - }); + let shown = max.map(f64::from).filter(|m| *m > 0.0); + let empty = if max.is_some() { tr("Auto") } else { tr(MIXED) }; + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = + field_row(ui, tr("Max lines"), |ui, fw| widgets::number_field_or(ui, Id::new("ty_maxlines"), shown, empty, fw, 1.0)) + { + app.run("text.maxLines", json!({"ids": ids, "lines": v.round().clamp(0.0, 10_000.0)})); + } } } else if let (2, Some(face)) = (tab, &vf) { variable_axes(app, ui, ids, styles, face); @@ -542,13 +537,14 @@ fn type_settings( } } -/// A Type settings row: a label, then `add` with the rest of the width. -fn field_row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui, f32) -> R) -> R { +/// A Type settings row: a label, then `add` with the rest of the width. A label wider than its +/// column (French, say) narrows the field rather than the row outgrowing the popover, which then +/// widened every frame (#1029). +pub(crate) fn field_row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui, f32) -> R) -> R { let t = Tokens::get(ui.ctx()); - let w = ui.available_width(); ui.horizontal(|ui| { ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(label).font(regular(11.0)).color(t.text))); - add(ui, w - 118.0) + add(ui, ui.available_width()) }) .inner } diff --git a/crates/ui-egui/src/ui_scale_tests.rs b/crates/ui-egui/src/ui_scale_tests.rs index 018c963a..4f2a478b 100644 --- a/crates/ui-egui/src/ui_scale_tests.rs +++ b/crates/ui-egui/src/ui_scale_tests.rs @@ -157,14 +157,18 @@ fn the_interface_size_menu_works_from_the_keyboard() { use egui::{Event, Key, Modifiers, Popup}; // A key press, then two quiet frames for the focus (and the name a screen reader reads) to settle. - let press = |u: &mut Ui, key: Key| { - u.frame(vec![ - Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, - Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, - ]); + let press_mod = |u: &mut Ui, key: Key, modifiers: Modifiers| { + u.frame_with( + vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + modifiers, + ); u.frame(vec![]); u.frame(vec![]); }; + let press = |u: &mut Ui, key: Key| press_mod(u, key, Modifiers::NONE); let open = |u: &mut Ui| { // The Preferences dialog is not modal for the keyboard: Tab crosses the main window first. let mut tabs = 0; @@ -201,6 +205,14 @@ fn the_interface_size_menu_works_from_the_keyboard() { press(&mut u, Key::ArrowDown); } assert_eq!(focused_name(&u.ctx), "125%"); + for expected in ["150%", "175%", "200%", "200%"] { + press(&mut u, Key::Tab); + assert_eq!(focused_name(&u.ctx), expected, "Tab stays in the size menu"); + } + for expected in ["175%", "150%", "125%"] { + press_mod(&mut u, Key::Tab, Modifiers::SHIFT); + assert_eq!(focused_name(&u.ctx), expected, "Shift+Tab stays in the size menu"); + } press(&mut u, Key::Enter); assert_eq!(chosen(&u), json!(1.25)); assert!(!Popup::is_any_open(&u.ctx), "picking a size closes the menu"); diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 1740c9a7..7947950a 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -95,7 +95,7 @@ pub fn guard(app: &mut FigApp, ctx: &Context, f: impl FnOnce(&mut FigApp)) { let roots = app.automation_roots.clone(); let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| figcraft_engine::file_access::confined(roots.as_ref(), || f(&mut *app)))); if let Err(e) = res { - let msg = e.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| e.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); + let msg = e.downcast_ref::<&str>().map(ToString::to_string).or_else(|| e.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); log::error!("UI panic: {msg}"); app.toast(format!("Internal error (please report): {msg}")); ctx.request_repaint(); @@ -322,8 +322,37 @@ fn close_current(app: &mut FigApp, ctx: &Context) { ctx.request_repaint_of(ViewportId::ROOT); } -/// The unsaved-changes question when a window closes. +/// What the unsaved-changes question was answered with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CloseAnswer { + /// Save every file with unsaved changes, then close (Save as for a file without a path). + Save, + /// Close and lose the changes. + DontSave, + /// Keep the window open. + Cancel, +} + +/// The unsaved-changes question when a window closes: Don't Save / Cancel / Save, as the other +/// Craft apps ask it. pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { + if std::mem::take(&mut app.ui.save_on_close) { + save_then_close(app, ctx); + return; + } + // A Save as this question started: once its dialog is answered, closing goes on if the file + // was saved; cancelled or failed, the window stays open. + if let Some(uid) = app.ui.close_after_save_as { + if app.pending_dialog.is_some() { + return; + } + app.ui.close_after_save_as = None; + // Saved: go on with the other files, then close. + if app.session.docs.iter().find(|d| d.uid == uid).is_none_or(|d| !d.is_dirty()) { + save_then_close(app, ctx); + } + return; + } if !app.ui.confirm_close { return; } @@ -333,20 +362,77 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { return; } let t = crate::theme::Tokens::get(ctx); - let (mut open, mut discard, mut cancel) = (true, false, false); + let mut open = true; + let mut answer = None; crate::dialogs::window(tr("Unsaved changes"), ctx).order(egui::Order::Foreground).open(&mut open).show(ctx, |ui| { ui.set_width(340.0); - crate::widgets::label(ui, &trf("Unsaved changes in {names}. Close the window and lose them?", &[("names", &names.join(", "))]), t.text); + crate::widgets::label(ui, &trf("Unsaved changes in {names}. Save them before closing?", &[("names", &names.join(", "))]), t.text); ui.add_space(12.0); ui.horizontal(|ui| { - discard = crate::widgets::button(ui, tr("Close without saving"), true, true).clicked(); - cancel = crate::widgets::button(ui, tr("Cancel"), false, true).clicked(); + if crate::widgets::button(ui, tr("Don't Save"), false, true).clicked() { + answer = Some(CloseAnswer::DontSave); + } + if crate::widgets::button(ui, tr("Cancel"), false, true).clicked() { + answer = Some(CloseAnswer::Cancel); + } + if crate::widgets::button(ui, tr("Save"), true, true).clicked() { + answer = Some(CloseAnswer::Save); + } }); + // Save is the default: Enter answers it when no button has the keyboard. + if answer.is_none() && ui.memory(|m| m.focused().is_none()) && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + answer = Some(CloseAnswer::Save); + } }); - app.ui.confirm_close = open && !discard && !cancel; - if discard { - close_current(app, ctx); + if !open { + answer = Some(CloseAnswer::Cancel); } + if let Some(a) = answer { + answer_close(app, ctx, a); + } +} + +/// Act on the unsaved-changes question's answer. +pub(crate) fn answer_close(app: &mut FigApp, ctx: &Context, answer: CloseAnswer) { + app.ui.confirm_close = false; + match answer { + CloseAnswer::Cancel => {} + CloseAnswer::DontSave => close_current(app, ctx), + CloseAnswer::Save => { + app.ui.save_on_close = true; + ctx.request_repaint(); + } + } +} + +/// Save each file of this window with unsaved changes, then close it. A file without a path (or +/// one saved as .fig, which is never overwritten silently) asks where with Save as, and closing goes +/// on when that dialog saved it ([`confirm_close`]). A save that fails shows why and keeps the +/// window open. +fn save_then_close(app: &mut FigApp, ctx: &Context) { + let files: Vec = app.session.docs.iter().filter(|d| app.shows(d.uid) && d.is_dirty() && d.branch_of.is_none()).map(|d| d.uid).collect(); + for uid in files { + let Some(i) = app.session.docs.iter().position(|d| d.uid == uid) else { continue }; + app.session.active = i; + let native = + app.session.docs.get(i).and_then(|d| d.path.as_deref()).is_some_and(|p| { + !std::path::Path::new(p).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("fig")) + }); + if !native { + app.ui.close_after_save_as = Some(uid); + app.save_as_dialog(); + if app.pending_dialog.is_none() { + // No dialog could start: stay open. + app.ui.close_after_save_as = None; + } + ctx.request_repaint(); + return; + } + if app.run("file.save", json!({})).is_none() { + return; + } + } + close_current(app, ctx); } /// Before the main window draws: windows asked to close close, closed files leave their windows diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 99276baa..fe63cd86 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -170,3 +170,191 @@ fn a_request_for_a_window_on_screen_wakes_it_while_the_main_window_is_hidden() { assert!(ui.try_recv().is_err(), "answered in the other window's frame"); assert!(woken.lock().unwrap().contains(&other), "the other window is asked to draw: {:?}", woken.lock().unwrap()); } + +/// Answers each Save as with the next of `paths` (none left: cancelled) and counts the dialogs +/// asked for. +struct SaveAs { + paths: Vec, + asked: std::rc::Rc>, +} + +impl crate::Services for SaveAs { + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> crate::DialogFuture { + self.asked.set(self.asked.get() + 1); + let next = if self.paths.is_empty() { None } else { Some(self.paths.remove(0)) }; + Box::pin(std::future::ready(next)) + } +} + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-close-save-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The main window with one file that has unsaved changes, saved at `path` first when given, and +/// the window asked to close (the question shows). +fn asking(path: Option<&std::path::Path>, save_as: Vec) -> (egui::Context, FigApp, std::rc::Rc>) { + let mut s = Session::new(); + if let Some(p) = path { + s.execute("file.saveAs", &json!({"path": p.to_string_lossy()})).unwrap(); + } + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + let ctx = egui::Context::default(); + let mut app = FigApp::new(s, Box::new(SaveAs { paths: save_as, asked: asked.clone() })); + frame(&ctx, &mut app, false); + frame(&ctx, &mut app, true); + assert!(app.ui.confirm_close, "the question shows"); + (ctx, app, asked) +} + +fn rects_in(path: &std::path::Path) -> usize { + let mut s = Session::new(); + s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + s.doc().unwrap().nodes().filter(|n| n.name.starts_with("Rectangle")).count() +} + +/// A few frames, as the window draws them. +fn settle(ctx: &egui::Context, app: &mut FigApp) { + for _ in 0..4 { + frame(ctx, app, false); + } +} + +#[test] +fn the_unsaved_changes_question_offers_save_dont_save_and_cancel() { + // As the other Craft apps ask it (#1170): Don't Save, Cancel, and Save as the default. + let (ctx, mut app, _) = asking(None, vec![]); + ctx.enable_accesskit(); + frame(&ctx, &mut app, false); + let out = ctx.run_ui(RawInput::default(), |ui| app.ui(ui)); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let buttons: Vec = + nodes.iter().filter(|(_, n)| n.role() == egui::accesskit::Role::Button).filter_map(|(_, n)| n.label().map(str::to_string)).collect(); + for want in ["Don't Save", "Cancel", "Save"] { + assert!(buttons.iter().any(|b| b == want), "{want} in {buttons:?}"); + } + assert!(!buttons.iter().any(|b| b == "Close without saving")); +} + +#[test] +fn save_saves_a_file_with_a_path_and_then_closes() { + let d = temp_dir("path"); + let path = d.join("a.figcraft"); + let (ctx, mut app, asked) = asking(Some(&path), vec![]); + assert_eq!(rects_in(&path), 0); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + settle(&ctx, &mut app); + assert_eq!(rects_in(&path), 1, "saved where it was"); + assert_eq!(asked.get(), 0, "no Save as for a file with a path"); + assert!(app.ui.quit, "then the window closes"); + std::fs::remove_dir_all(&d).ok(); +} + +#[test] +fn save_waits_for_a_panel_field_to_commit() { + // The click on Save takes the keyboard from a panel field, which commits what was typed next + // frame: the save comes after that commit (stood in for by a command run before the frame). + let d = temp_dir("field"); + let path = d.join("a.figcraft"); + let (ctx, mut app, _) = asking(Some(&path), vec![]); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + app.session.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + settle(&ctx, &mut app); + assert_eq!(rects_in(&path), 2, "the committed edit is saved too"); + assert!(app.ui.quit); + std::fs::remove_dir_all(&d).ok(); +} + +#[test] +fn save_asks_where_for_an_untitled_file_and_closes_once_saved() { + let d = temp_dir("untitled"); + let path = d.join("new.figcraft"); + let (ctx, mut app, asked) = asking(None, vec![path.clone()]); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + settle(&ctx, &mut app); + assert_eq!(asked.get(), 1, "Save as asks where"); + assert_eq!(rects_in(&path), 1, "saved there"); + assert!(app.ui.quit, "then the window closes"); + std::fs::remove_dir_all(&d).ok(); +} + +#[test] +fn a_cancelled_save_as_or_a_failed_save_keeps_the_window_open() { + // Save as cancelled: nothing is written, the file keeps its changes and the window stays. + let (ctx, mut app, asked) = asking(None, vec![]); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + settle(&ctx, &mut app); + assert_eq!(asked.get(), 1); + assert!(!app.ui.quit && !app.ui.confirm_close, "open, and not asking again"); + assert!(app.session.ds().unwrap().is_dirty()); + assert_eq!(app.ui.close_after_save_as, None); + + // The save fails (its folder is gone): the error shows and the window stays. + let d = temp_dir("gone"); + let path = d.join("a.figcraft"); + let (ctx, mut app, _) = asking(Some(&path), vec![]); + std::fs::remove_dir_all(&d).unwrap(); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + settle(&ctx, &mut app); + assert!(!app.ui.quit, "a failed save keeps the window open"); + assert!(app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn dont_save_closes_and_cancel_keeps_the_window() { + let d = temp_dir("answers"); + let path = d.join("a.figcraft"); + let (ctx, mut app, _) = asking(Some(&path), vec![]); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Cancel); + settle(&ctx, &mut app); + assert!(!app.ui.quit && !app.ui.confirm_close); + frame(&ctx, &mut app, true); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::DontSave); + settle(&ctx, &mut app); + assert!(app.ui.quit, "Don't Save closes"); + assert_eq!(rects_in(&path), 0, "without saving"); + std::fs::remove_dir_all(&d).ok(); +} + +#[test] +fn one_save_saves_every_untitled_file_of_the_window_then_closes() { + // Two new files with changes: Save asks where for each in turn, then the window closes, + // without asking the question again in between (#1173 review). + let d = temp_dir("two"); + let (a, b) = (d.join("a.figcraft"), d.join("b.figcraft")); + let (ctx, mut app, asked) = asking(None, vec![a.clone(), b.clone()]); + app.session.execute("file.new", &json!({"name": "Second"})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + for _ in 0..8 { + frame(&ctx, &mut app, false); + assert!(!app.ui.confirm_close, "not asked again"); + } + assert_eq!(asked.get(), 2); + assert_eq!((rects_in(&a), rects_in(&b)), (1, 1), "both saved"); + assert!(app.ui.quit); + std::fs::remove_dir_all(&d).ok(); +} + +#[test] +fn enter_answers_save() { + let d = temp_dir("enter"); + let path = d.join("a.figcraft"); + let (ctx, mut app, _) = asking(Some(&path), vec![]); + frame(&ctx, &mut app, false); + let enter = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + let input = RawInput { events: vec![enter(true), enter(false)], ..Default::default() }; + ctx.run_ui(input, |ui| { + crate::windows::on_close_request(&mut app, ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); + settle(&ctx, &mut app); + assert_eq!(rects_in(&path), 1, "Enter saved"); + assert!(app.ui.quit); + std::fs::remove_dir_all(&d).ok(); +} diff --git a/crates/ui-egui/tests/canvas_pan_release.rs b/crates/ui-egui/tests/it/canvas_pan_release.rs similarity index 98% rename from crates/ui-egui/tests/canvas_pan_release.rs rename to crates/ui-egui/tests/it/canvas_pan_release.rs index c54d20dc..cd1a6e1c 100644 --- a/crates/ui-egui/tests/canvas_pan_release.rs +++ b/crates/ui-egui/tests/it/canvas_pan_release.rs @@ -1,6 +1,5 @@ //! A canvas pan (middle button or Space + primary) ends when its button is released, even when the //! release happens over a panel: returning to the canvas with no button held must not move the view. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, vec2}; use figcraft_engine::Session; diff --git a/crates/ui-egui/tests/comment_open_other_page.rs b/crates/ui-egui/tests/it/comment_open_other_page.rs similarity index 100% rename from crates/ui-egui/tests/comment_open_other_page.rs rename to crates/ui-egui/tests/it/comment_open_other_page.rs diff --git a/crates/ui-egui/tests/file_menu_pdf.rs b/crates/ui-egui/tests/it/file_menu_pdf.rs similarity index 97% rename from crates/ui-egui/tests/file_menu_pdf.rs rename to crates/ui-egui/tests/it/file_menu_pdf.rs index 30b847b7..812529e0 100644 --- a/crates/ui-egui/tests/file_menu_pdf.rs +++ b/crates/ui-egui/tests/it/file_menu_pdf.rs @@ -1,6 +1,5 @@ //! File > Export frames to PDF: the menu asks where to save and writes the frames' PDF there, //! one visible top-level frame per page. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::path::PathBuf; diff --git a/crates/ui-egui/tests/guide_label_narrow.rs b/crates/ui-egui/tests/it/guide_label_narrow.rs similarity index 97% rename from crates/ui-egui/tests/guide_label_narrow.rs rename to crates/ui-egui/tests/it/guide_label_narrow.rs index 6c4276ad..464fbfed 100644 --- a/crates/ui-egui/tests/guide_label_narrow.rs +++ b/crates/ui-egui/tests/it/guide_label_narrow.rs @@ -1,5 +1,3 @@ -#![allow(clippy::unwrap_used)] - use egui::{Pos2, RawInput, Rect, Vec2, vec2}; use figcraft_engine::Session; use figcraft_ui_egui::{FigApp, NoServices}; diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs new file mode 100644 index 00000000..007f450d --- /dev/null +++ b/crates/ui-egui/tests/it/main.rs @@ -0,0 +1,11 @@ +//! The crate's integration tests, built as one test binary. + +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod canvas_pan_release; +mod comment_open_other_page; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod file_menu_pdf; +#[allow(clippy::unwrap_used)] +mod guide_label_narrow; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod recent_file_names; diff --git a/crates/ui-egui/tests/recent_file_names.rs b/crates/ui-egui/tests/it/recent_file_names.rs similarity index 97% rename from crates/ui-egui/tests/recent_file_names.rs rename to crates/ui-egui/tests/it/recent_file_names.rs index 461d0c73..41e37ec2 100644 --- a/crates/ui-egui/tests/recent_file_names.rs +++ b/crates/ui-egui/tests/it/recent_file_names.rs @@ -1,5 +1,4 @@ //! Recent file rows keep their full accessible name. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use egui::{Context, Id, Pos2, RawInput, Rect, accesskit::Role, vec2}; use figcraft_engine::Session; diff --git a/crates/ui-egui/tests/type_settings_long_labels.rs b/crates/ui-egui/tests/type_settings_long_labels.rs new file mode 100644 index 00000000..113b4daf --- /dev/null +++ b/crates/ui-egui/tests/type_settings_long_labels.rs @@ -0,0 +1,47 @@ +//! #1029, through the whole app: in a language whose labels run past Type settings' label column, +//! the popover keeps its width while it stays open. French does with Inter (release builds, the +//! report); Georgian does with egui's own fonts too (CI, which builds without craft-fonts). Its own +//! test binary, since the UI language is one setting for the whole process. +#![allow(clippy::unwrap_used)] + +use egui::{Id, Popup, Pos2, RawInput, Rect, vec2}; +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, NoServices}; +use serde_json::json; + +/// The Type settings popover's width over the frames it is open, the UI in `language`. +fn widths(language: &str) -> Vec { + let mut s = Session::new(); + s.execute("pref.language", &json!({"language": language})).unwrap(); + // Truncating text also shows Max lines. + let text = s.execute("text.create", &json!({"text": "Type", "props": {"textAutoResize": "truncate"}})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [text]})).unwrap(); + let ctx = egui::Context::default(); + let mut app = FigApp::new(s, Box::new(NoServices)); + let open = Id::new("ty_settings_open"); + let mut widths = Vec::new(); + for i in 0..40 { + if i == 3 { + Popup::open_id(&ctx, open); + } + let raw = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(i as f64 / 60.0), ..Default::default() }; + ctx.run_ui(raw, |ui| app.ui(ui)).textures_delta.clear(); + // From the frame after the one it opened on (egui's sizing pass). + if i > 3 { + assert!(Popup::is_id_open(&ctx, open), "{language}: Type settings closed on frame {i}"); + widths.push(ctx.memory(|m| m.area_rect(open)).unwrap().width()); + } + } + widths +} + +#[test] +fn type_settings_keeps_its_width_with_labels_longer_than_their_column() { + for language in ["ka", "fr"] { + let w = widths(language); + assert_ne!(figcraft_ui_egui::i18n::tr("Paragraph indent"), "Paragraph indent", "{language} is shown"); + // The width it asks for (`Popup::width`), every frame. + assert!(w.iter().all(|x| (x - 264.0).abs() < 0.01), "{language}: Type settings widens: {} → {}", w[0], w[w.len() - 1]); + } +} diff --git a/docs/boards-parity.md b/docs/boards-parity.md new file mode 100644 index 00000000..b6928dd7 --- /dev/null +++ b/docs/boards-parity.md @@ -0,0 +1,61 @@ +# Boards parity + +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** major (created: board files, the first slice of the whiteboard workflow) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) + +**Breadth ~35%, ready ~20% (estimated, erring low).** A board is a FigCraft file of its own kind +(`file.newBoard`) with sticky notes, shapes with text, connectors that follow what they join, +marker, highlighter and eraser, stamps, text and sections, edited together in the existing live +sessions. The facilitation tools (timer, voting, widgets), templates, tables, mind maps and the +rest of the tail are missing. Remaining: ~30–60 h. Tracked as area 22 of +[`target-app-parity.md`](target-app-parity.md) (not in its weighted sum: the target there is the +design product). The work item is gap 24 in [`gaps.md`](gaps.md). + +## How boards are built + +Board objects are ordinary layers with a small `board` field on the node (`figcraft_doc::BoardItem`), +so rendering, hit testing, undo, saving, SVG/PNG/PDF export, copy and paste and live sessions work +on them unchanged. The file kind (`Document.kind`) is saved in `.figcraft` and reaches people who +join a live session in their snapshot. What follows from the metadata (a sticky's text size, a +shape's text room, connector routes and their labels) is recomputed in the engine's normalize pass, +deterministically from the document, so local edits, undo and edits from others in a live session +all end with the same geometry. + +## Features + +| Feature | Status | Notes | +|---|---|---| +| Board files: File ▸ New board, the home screen's New board card, a light page | done | `file.newBoard`; the kind shows in `inspect_document`, tabs and file rows (a note mark; design files a pen nib) | +| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Resources, Actions, the Design panel's button | +| Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL), connector straight/elbow and its arrow; the Design panel opens with its button or View ▸ Design panel | +| Prototyping and Dev Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why | +| Sticky notes | done | a square frame (200 wide, growing taller through auto layout), 8 soft colours, a soft shadow, the note's text size shrinking as it grows (24 → 11, by characters and lines), the author's name at the bottom; a click places one and starts typing; Enter or a double-click edits; Shift+Cmd/Ctrl+Enter adds the next one beside it | +| Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks | +| Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar | +| Shape auto-fit to its text, more shapes (cylinder, cross, star…), shape libraries | missing | | +| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight or elbow, arrowheads (the stroke's caps), a label in the middle (double-click to add); re-routed when either end moves or resizes; a deleted end stays where it was | +| Curved connectors, connector waypoints, dragging an end to re-attach | missing | ends are attached when drawn or through `board.connector` | +| Quick-create handles (drag out a connected copy) | missing | | +| Marker and highlighter | done | the Pencil's stroke fitting with board presets: marker opaque, highlighter wide, see-through and multiplying | +| Eraser | done | drag over marker and highlighter strokes to remove them, one undo step | +| Pen colours and widths in the toolbar | partial | `tool.marker {color}`, `tool.highlighter {color}`; colour changes on a selected stroke from the contextual bar | +| Stamps | done | thumbs up, heart, plus one, star, question, check: round badges drawn as vectors, the placer's name on hover | +| Stamps sticking to the object they land on, the stamp wheel, emotes, stickers | missing | | +| Text and sections | done | the existing Text tool and sections | +| Live sessions: cursors, selections, cursor chat, spotlight | done | the existing sessions; board objects, connectors following moved objects, colours and stamps sync both ways (checked with two app instances) | +| Timer and music | missing | | +| Voting sessions | missing | | +| Board widgets | missing | design-file widgets exist (`crates/engine/src/widgets.rs`), not wired to boards | +| Templates (retrospective, brainstorm, flow chart…) | missing | | +| Tables | missing | | +| Mind maps | missing | | +| Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files | +| Audio and huddles | missing | | +| AI features (summaries, sorting stickies, generating boards) | missing | | +| Dot grid on the page | missing | the canvas grid is drawn over the render; a dot grid needs a render pass under the layers | +| `.jam` import and export | missing | see [`file-format-parity.md`](file-format-parity.md) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-24 | major | Created with board files (M12): sticky notes, shapes with text, connectors, ink and eraser, stamps, the board toolbar and contextual bar, live-session sync | diff --git a/docs/cli.md b/docs/cli.md index 699b6ba2..91288f14 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -4,7 +4,9 @@ FigCraft without a window: inspect, export, convert and render documents, run co commands, serve MCP and measure performance. Build it with `cargo build --release -p figcraft-cli` (binary `target/release/figcraft-cli`). -Bad arguments print usage and exit with code 2; failures exit with 1. The CLI never panics on input. +`figcraft-cli --help` (or `-h`, or `help`) prints usage on stdout and exits with 0, and so does +`--help` or `-h` after a subcommand (`figcraft-cli mcp --help`). Bad arguments print usage and exit +with code 2; failures exit with 1. The CLI never panics on input. ## Fonts @@ -51,6 +53,10 @@ page 1 "Screens": 380 nodes, 4 top-level - `` is a directory: one file per top-level frame of the page (default the first page), named after the frame (`Home.png`, `Settings.png`…); with no frames, one file per top-level layer. + The name is kept as it is, except for the characters file systems refuse (`/ \ : * ? " < > |` + and control characters), which become `_`, and leading or trailing spaces and dots, which are + dropped. A name is cut at 100 characters. The app's export replaces the same characters (there a + `/` makes a folder). Two layers with the same name get `Name 2`, `Name 3`… - `` has an image extension: one file with the given `--node`, or the whole page composited. - `--node` takes a layer id (`12` or `0:12`) or a layer name. - `--format fig` (or an `` ending in `.fig`) writes the whole file as `.fig`, experimental: @@ -186,7 +192,9 @@ Serve MCP on stdio: headless by default, or attached to a running app started wi `figcraft --control PORT`. See [mcp.md](mcp.md). The automation roots (each repeatable; existing folders) confine the files the agent's calls read (inside a read or write root) and write (inside a write root); everything else is refused ([mcp.md › Confining file access](mcp.md#confining-file-access)). -With `--attach` they are refused: start the app with them instead. The headless session can host, join and publish +They apply to the headless session only, and that is why `--attach` refuses them: an attached server +is confined by the app it attaches to, from the flags that app was started with. The headless +session can host, join and publish live sessions (`collab.*`: without a key on a server open to the public, with `collab.serverInfo`, `collab.manageFile` for the owner token and `collab.report`) and use plugin directories (`plugin.directory.*`); the one-shot `run` can't (no network link). diff --git a/docs/components-parity.md b/docs/components-parity.md index fad4c6fe..7f7c162f 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -29,7 +29,7 @@ accounts). | Assets panel: folders by page, frame and slash names (fold away), thumbnails, search, drag into frames and auto layout slots | done | virtualized; `component.list` gives each one's `folder` | | Hidden from publishing: a leading `.` or `_` in the name | done | the name decides both ways (adding the prefix takes a published component out, removing it brings it back); Hide when publishing in the Assets panel's context menu and Libraries ▸ Hide when publishing (see [ui-parity](ui-parity.md)) | | Instances pasted into other files follow their mains | done | missing main restorable | -| Component playground (try properties without placing) | partial | variant, boolean and text properties in the Inspect tab and `component.playground`; instance swaps and slots have no control there yet (0.5–1 h) | +| Component playground (try properties without placing) | partial | variant, boolean, text and instance-swap properties in the Inspect tab (the swap menu lists the preferred components first, then every component in the file, as in the Design panel) and `component.playground`; slots have no control there yet | | Real-world libraries QA | **not checked** | [gaps](gaps.md) #3 | The behaviour of instances, overrides, variants and swaps is checked against the public @@ -77,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Instance-swap properties in the component playground: the Inspect tab's Playground shows a dropdown for each, with the same menu as the Design panel's (the property's preferred components first, then every component in the file). The component picked changes the preview and the code only, never the file. Left: slots in the playground. Numbers unchanged. | | 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. | | 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged | | 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c9ae5331..3941bd8a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -256,7 +256,7 @@ separate calls. Returns `{queued}`. Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`. -### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}` +### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?, devCompareVersion?}` Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`, `home` (bool), `homeSection` (`recents`, `online` or `templates`: which section the home screen shows, as `ui.inspect` reports it; opening @@ -293,13 +293,14 @@ closes it), `commentDraft` (`{x, y}`: start a new comment there), `commentFilter text, focused with the cursor at its end: an `@` there shows the mention picker), `commentEdit` (a message index of the open thread to edit, 0 for the first; `null` stops), `presentComments` (bool: the presenter shows the presented frame's comments, also Shift+C there). Dev Mode: `devCompare` (a -ready-for-dev frame or section id opens the Compare changes view, like `dev.compare {id, show: -true}`; `null` closes it) with `devCompareMode` (`sideBySide|overlay`) and `devCompareSplit` (the +frame or section id opens the Compare changes view, like `dev.compare {id, show: +true}`; `null` closes it) with `devCompareVersion` (a version id to compare with; `null`: when it +was marked ready for dev), `devCompareMode` (`sideBySide|overlay`) and `devCompareSplit` (the overlay slider, 0–1), `annotationFilter` (`all`, `none` for uncategorised, or a category id or name: the annotations the canvas shows). `resetFrameStats` (true: start `framesDrawn` and `repaintCauses` over). Returns `ui.inspect` (which reports `stylePicker`, `stylePickerView`, `comments`: `open`, `draft`, `filter`, `sort`, `search`, `editing` and the -`mentions` the picker offers, `devCompare` (`{id, mode, split}` or `null`), `devFocus` (the frame the +`mentions` the picker offers, `devCompare` (`{id, mode, split, version}` or `null`), `devFocus` (the frame the focus view shows, or `null`) and `annotationFilter`). ### `ui.screenshot {path?}` diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md index cba5e81d..bf3cc737 100644 --- a/docs/dev-mode-parity.md +++ b/docs/dev-mode-parity.md @@ -1,23 +1,26 @@ # Dev Mode parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dev resources links; inspect behaviour suite and fixes: colour formats, rem, blend, positions from the frame, CSS for rotation, gradients and text, icons and images as assets; code connect and Dev Mode plugins) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) -**Breadth ~96%, ready ~72% (estimated).** The inspect surface is broad, components connect -to the code components of a repository and layers carry dev resources links; left: import lines -inside generated layer trees. Remaining: 0–1 h. +**Breadth ~97%, ready ~76% (estimated).** The inspect surface is broad, components connect +to the code components of a repository (with their imports, also inside generated layer trees), +changes compare with any saved version and the canvas only inspects in Dev Mode. Left: measurements +kept on the canvas, a read-only layers panel in Dev Mode, publishing mappings from the code +repository. Remaining: 2–4 h. | Feature | Status | Notes | |---|---|---| -| Dev Mode toggle (Shift+D), Inspect panel | done | | +| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel and dropped files still edit (ours to tighten) | | Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) | | Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration | | Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours | | Annotations: pinned properties, categories, coloured callouts, filter | done | | -| Ready for dev list, statuses, "Changed" after edits | done | | +| Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) | | Focus view (one frame, previous / next) | done | | -| Compare changes (before/after side by side or overlaid, property diffs) | done | only against the snapshot taken when a frame was marked ready; not against any named version | -| Code connect (map components to repository code) | done | per language (React, Leptos, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected | -| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab; no plugin UI at run time, no third-party integrations | +| Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions | +| Code connect (map components to repository code) | done | per language (React, Leptos, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. No CLI publishing mappings from the code repository | +| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab, with their panels (callbacks can only read); no third-party integrations | +| Measurements (redlines on hover, Alt in the design) | partial | distances between the selection and the hovered layer, its parent or a guide, auto layout padding and gaps (Shift+Alt); measurements kept on the canvas for others aren't there | | VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors | | Dev resources links on layers | done | a URL and a name per link on any layer (`devResource.*`, saved, synced, undoable); instances list their main component's (and its set's) read-only; URLs checked as http/https and never fetched (no page titles: a link without a name is named after its URL); the Inspect tab lists, adds, edits and removes them; `get_node` returns them | @@ -25,6 +28,7 @@ inside generated layer trees. Remaining: 0–1 h. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Dev Mode tail (gap 16): generated layer trees (React, Leptos, SwiftUI, Compose) start with the import lines of the connected components they call, once each, also those in a component's definition, in slots an instance fills and placed by a connected template's instance swaps; Compare changes takes any saved version as the before (`dev.compare {version}`, the view's Compare with menu; frames that aren't ready for dev compare with versions). Behaviour suite (5 tests; 4 failed before the fixes): the Dev Mode canvas only inspects (no moving, resizing, drawing, text or vector editing; menus, shortcuts, nudges, opacity keys and paste refuse design edits with a note; toolbar drawing tools go back to the design), the ready list filters and badges Changed items, statuses are refused on layers inside frames. Read-only plugins run from the Inspect tab show their panels (the runtime's panels, reused). Breadth 96% → 97% (measurements kept on the canvas and the read-only layers panel counted as missing), ready 72% → 76%, 0–1 h → 2–4 h | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | | 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Dev Mode breadth 90% → 93%, ready 64% → 70%, hours unchanged (1–3, dev resources); feature breadth and ready for real work unchanged at this rounding | | 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–720 h | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index adf0b0f2..ff796416 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (`.figcraft` carries the file kind (boards; `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -18,9 +18,9 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | +| `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | -| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | +| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | ## Import (place / paste / drop) @@ -51,7 +51,7 @@ export limit, not a native document nesting limit. |---|---|---|---|---| | PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them; sides round up to whole pixels | — | | JPG | yes | yes | white where transparent; fixed quality (90) | — | -| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `` with one `` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — | +| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `` with one `` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — | | PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 | | WebP | — | yes (ours) | | — | | Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — | @@ -122,10 +122,10 @@ each. image adjustments, glass settings other than frost, springs other than Gentle (written as ease out), set-variable, set-variable-mode and conditional actions, scroll-to offsets, text decoration looks, variable font axes, text on a path, nested list levels, list spacing, hanging punctuation, - leading trim, OpenType features, per-layer operations inside boolean groups, grid tracks of fixed - or content size, different stroke ends on lines. Not written at all: stroke outlines of dashed and - per-side strokes and of inside or outside strokes on polygons, stars and vectors, glyph outlines - of text (a reader lays text out with its own fonts), version history and branches. + leading trim, OpenType features, per-layer operations inside boolean groups, different stroke ends + on lines. Not written at all: stroke outlines of dashed and per-side strokes and of inside or + outside strokes on polygons, stars and vectors, glyph outlines of text (a reader lays text out + with its own fonts), version history and branches. - **Checked by opening written files in other software:** the example documents and a small test file open there, and layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. A file saved again there and read back keeps the layer tree and properties; two @@ -165,6 +165,9 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Boards (M12): `.figcraft` saves the file kind (`kind: "board"`, absent for design files) and each board object's `board` metadata; the `.jam` row points at boards | +| 2026-10-15 | minor | `.fig` import (#279, #281; #266, #267): resized instances lay out their layers by their constraints, and explicit variable modes whose collection is named by its asset key come across (before, they were dropped and bound layers resolved in the default mode); fixture-tested, real files still owed. `.figcraft`: atomic saves and exports through unique staging files (#284), optional auto-save (#305). SVG export keeps a nested frame's fill and stroke inside its parent's clip (#327). Numbers unchanged | +| 2026-10-20 | minor | `.fig` export writes grid track sizes (`gridColumnsSizing` / `gridRowsSizing`): fixed px, fractional fill and content-sized tracks keep their sizes and come back through our own importer (#465). Tracks whose size the format can't hold (a negative or out-of-range value) keep their id and read back equal, with a warning. Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index 9b1a770c..40f97032 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | -| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | +| 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | | 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | @@ -29,7 +29,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) | | 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | | 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | -| 16 | Dev Mode: import lines inside generated layer trees; the editor extension is left to the MCP server (code connect, Dev Mode plugins and dev resources landed) | Feature | 0–1 | [dev-mode-parity](dev-mode-parity.md) | +| 16 | Dev Mode: measurements kept on the canvas, a read-only layers panel, publishing mappings from the code repository; the editor extension is left to the MCP server (import lines, compare with versions and an inspect-only canvas landed 2026-10-15) | Feature | 2–4 | [dev-mode-parity](dev-mode-parity.md) | | 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | | 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | | 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | @@ -37,6 +37,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 21 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) | | 22 | Screen readers never run; browser has no accessibility tree | UI/UX | 4–8 | [ui-parity](ui-parity.md) | | 23 | CMYK output intent for print PDFs | File format | 2–4 | [file-format-parity](file-format-parity.md) | +| 24 | Boards: the whiteboard tail (timer, voting, widgets, templates, tables, mind maps…) | Feature | 30–60 | [boards-parity](boards-parity.md) | ## Entries @@ -178,7 +179,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). -### 5. Localization: English, reviewed Russian, plus unreviewed drafts +### 5. Localization: English, reviewed Russian and Turkish, plus unreviewed drafts - **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts and the Latin American Spanish overlay, the strings added since each catalog was drafted (1–5% of each), Hindi, Arabic and @@ -308,11 +309,14 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock). ### 13. Vector tail: none left +- **Landed (2026-10-22):** the transform box around points selected in several layers at once, in + the page's axes: its corners and edges scale them, outside a corner rotates them and inside drags + them, each in its own layer and one undo step for all (`transform_tests`). - **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together, sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG, PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a - selected open path. Left: corner smoothing beyond rectangles and frames, the transform box around - points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. + selected open path. Left: corner smoothing beyond rectangles and frames, per-point caps and joins + on dashed, variable-width and brush strokes. - **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked; "Select layer" lists the group only where its result is), live @@ -371,11 +375,22 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe host, no spaces, capped) and never fetched: a link added without a name is named after its URL, and a link opens only when clicked. The Inspect tab lists, adds, edits and removes them; `get_node` / `inspect_document` return them as `devResources`. -- **Missing:** import lines for connected instances inside a - generated layer tree (only the selected layer's own import is shown); publishing mappings from - the code repository with a CLI. An editor extension is out of scope: the MCP server already - serves design data, code and the connected snippets to agents and editors. -- **Estimate:** 0–1 h. +- **Landed (2026-10-15):** generated layer trees (React, Leptos, SwiftUI, Compose) start with the + import lines of the connected components they call, once each, also those in a component's + definition, in the slots an instance fills and placed by a connected template's instance swaps. + Compare changes takes any saved version as the before (`dev.compare {version}`; the view's + Compare with menu), for any top-level frame or section. A Dev Mode behaviour suite: the canvas + only inspects (presses select, pan and comment; no moving, resizing, drawing, text or vector + editing), the menus, shortcuts, nudges, opacity keys and paste refuse design edits with a note + while Dev Mode's own edits still run, drawing tools from the toolbar return to the design; the + ready list filters and badges Changed items (`inspect.readyForDev {status: changed}`); statuses + are refused on layers inside frames. Read-only plugins run from the Inspect tab show their + panels. +- **Missing:** measurements kept on the canvas for others (only hover redlines); the layers panel + and files dropped on the canvas still edit in Dev Mode; publishing mappings from the code + repository with a CLI. An editor extension is out of scope: the MCP server already serves design + data, code and the connected snippets to agents and editors. +- **Estimate:** 2–4 h. ### 17. Branching and merging - **Landed (2026-10-11):** branches of a file (`branch.*`): File ▸ Create branch… (or the Branches @@ -440,12 +455,27 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe repository (licensing). - **Estimate:** 2–4 h once an openly licensed profile is found. +### 24. Boards: the whiteboard tail +- **Landed (2026-10-24, M12):** board files (`file.newBoard`) with sticky notes, shapes with text, + connectors that re-route when what they join moves, marker, highlighter and eraser, stamps, text + and sections; a board toolbar and a contextual bar; live sessions sync it all. +- **Missing:** timer and music, voting sessions, board widgets, templates, tables, mind maps, washi + tape, code blocks, audio and huddles, quick-create handles, curved connectors and waypoints, + stamps sticking to the objects they land on, a dot grid, `.jam` files + ([`boards-parity.md`](boards-parity.md)). +- **User impact:** boards cover a brainstorm or a flow sketch; facilitated workshops need the + timer, voting and templates. +- **Estimate:** 30–60 h. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | +| 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | +| 2026-10-15 | minor | Gap 16: import lines inside generated layer trees, compare with any saved version, an inspect-only Dev Mode canvas (menus and shortcuts refuse design edits), Changed in the ready list, statuses only on sections and top-level frames, read-only plugins' panels from the Inspect tab. Left: measurements kept on the canvas, a read-only layers panel, publishing mappings with a CLI; 0–1 h → 2–4 h | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | | 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index df822981..f5be7263 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -12,13 +12,13 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu | Feature | Figma | FigCraft (macOS / Windows / Linux / web) | Hours | |---|---|---|---| -| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path | +| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path | | Effects (blurs, glass, noise) | GPU | CPU | in the above | | HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — | | Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 | | Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — | | Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 | -| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes. Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 | +| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes; a minimised or covered window still answers control and MCP requests, while screenshots and input fail at once until it is on screen (#313, #332, #720). Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 | | Screen readers | partial | AccessKit tree on desktop (names, roles, states), never tested with a real screen reader; none in the browser | 4–8 + human QA | | Keyboard layouts (non-US) | yes | keymap uses logical keys; not checked on AZERTY/QWERTZ/JIS | 1–2 | @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Community fixes (#290, #306, #312, #313, #328, #332, #346, #474): coarse zoom previews when renders from scratch are slow; a minimised or covered window answers control and MCP requests (screenshots and input fail at once while it is hidden); `.figcraft` files open from Finder on macOS; a message box when the window can't open on Windows; on Linux the window id matches the desktop entry, the RPM requires glibc 2.35 and user fonts under `XDG_DATA_HOME` are found. Numbers unchanged | | 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged | | 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged | | 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 50fcd4ec..aa4cbb52 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (a missing translation fails no check: `cargo xtask i18n-report` lists them; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -37,7 +37,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v only the first preferred language counts, since that list also holds the languages added for a keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional tag falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the - other Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans, + other Latin American and US Spanish tags to es-419; regional ru, tr, cs and id tags (ru-RU, + tr-TR, cs-CZ, id-ID) to their catalogs; zh, zh-CN, zh-SG and zh-Hans read zh-Hans, while Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the next OS language, never Simplified) or the browser's language on the web. `pref.language {language}` sets it from the CLI, MCP and the control channel. @@ -45,11 +46,11 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v (`es-419.toml` over `es.toml`); its UI strings, command labels and menu wordings read the overlay, then the base language, then English. - **Check:** `cargo test -p figcraft-ui-egui i18n` fails on keys the code uses that `en.toml` - lacks, keys no code uses, unknown command ids, placeholders that differ from English, overlay - entries that read as in their base language and, for complete languages (Spanish), anything - untranslated. The other catalogs are not held to it: a string added later shows English there - until someone translates it. Every draft translated every string on 2026-10-12; by 2026-10-17 - the strings added since left them at 95–99% (table below). + lacks, keys no code uses, unknown command ids, placeholders that differ from English and overlay + entries that read as in their base language. No language is held to full coverage: a string + added later shows English until someone translates it, and `cargo xtask i18n-report` (part of + `cargo xtask ci`) lists what each language lacks. Every draft translated every string on + 2026-10-12; by 2026-10-17 the strings added since left them at 95–99% (table below). - **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules (`trn` takes integers: Czech uses one/few/other and Russian one/few/many; French and Brazilian @@ -63,7 +64,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v - **Script support in the UI:** menus widen to their longest label; dropdowns, the two-column labels of the right panel and the home screen's cards keep a long translation on one line with an ellipsis and show it in full on hover (checked in German, the longest); no mirrored - (right-to-left) layout of panels. CJK translations are shorter than English; the same ellipsis + (right-to-left) layout of panels. Cyrillic, Turkish and Czech letters draw from the UI fonts + (no font added; Russian checked in screenshots). CJK translations are shorter than English; the same ellipsis and tooltip cover the few that don't fit (the sizing dropdowns). - **CJK UI fonts:** egui's fonts and Inter have no CJK glyphs. Once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, @@ -120,6 +122,8 @@ Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; it | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | A missing translation fails no check: Spanish is no longer held to full coverage, and the tests of one language's wording, names and plural forms are gone (the plural rules and language-tag tests stay). `cargo xtask i18n-report`, run by `cargo xtask ci`, lists each language's missing UI strings, command labels and menu wordings. Grade unchanged | +| 2026-10-15 | minor | Community catalogs (#257, #263, #469, #477, #288): Russian (`ru`, Русский, reviewed and approved by VanTan) and Turkish (`tr`, Türkçe, reviewed by a native speaker), Czech (`cs`, Čeština) and Indonesian (`id`, Bahasa Indonesia) drafts, and the Japanese draft caught up; Preferences ▸ Language lists them and System maps their regional tags. Catalogs may give few and many plural forms; Russian picks one, few or many by its integer rule (11–14 take many), the catalog supplying the text choosing the rule. Only Spanish is held to completeness. Counts re-measured: 1,587 UI keys (38 plural), 559 command labels and 31 menu wordings, 2,177 entries; Spanish 100%, Japanese 2,176, Czech and Indonesian 2,166, Russian and Turkish 2,165, the five older drafts 2,088–2,089 (95%). Localization 39% and 29–51 h unchanged at this rounding | | 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac | | 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog | diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..ccaeb401 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -34,6 +34,10 @@ of the window itself (`screenshot` with `window`) needs it on screen: it is then ### Confining file access +The roots below belong to the **headless** side: the session `figcraft-cli mcp` runs itself, with +no window. A server attached with `--attach` runs the commands inside the app instead, so the app +confines them — from the flags it was started with, not from anything passed to the CLI. + ```sh figcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out ``` @@ -144,6 +148,8 @@ Or in a project's `.mcp.json`: there is none, so they are page coordinates then). Groups and boolean groups have no coordinate space of their own: a layer in a group is placed in the frame (or on the page) the group is in. `inspect_document` reports `x`/`y` the same way, plus `absoluteBounds` in page coordinates. + In `props`, `fill` and `stroke` also accept `"none"` (either case) or `null` as no paint, the + same as an empty `fills`/`strokes` list, so an outline-only shape can be made in one call. - Frames, groups, sections and masks: drawing a frame or section with the `pointer` tool around layers puts those fully inside it in it; sections go only on the page or in sections (moves, drags, pastes and new sections elsewhere are refused or land on the page); resizing a section @@ -267,9 +273,11 @@ Or in a project's `.mcp.json`: - **Styles stay true to their layers**: editing a styled fill, stroke, effect or layout grid list (`node.set`, `fill.set`, `fill.add`, a bound variable…) unlinks that style (`styles.fill` etc. go `null`), so a later `style.update` doesn't overwrite the edit; `style.apply` replaces variables - bound to what the style sets; a text style goes on text layers only. Names are trimmed per `/` - folder part. `style.move {styles, before?, folder?}` reorders (and with `folder`, renames into it), - `style.duplicate {style}` adds " copy" right below, `style.renameFolder {folder, name}`. + bound to what the style sets; a text style goes on text layers only. In one call, a style link + (`fillStyle`, `textStyle`…) wins over values it sets; set a deviation in a later call, which + unlinks it. Names are trimmed per `/` folder part. `style.move {styles, before?, folder?}` + reorders (and with `folder`, renames into it), `style.duplicate {style}` adds " copy" right + below, `style.renameFolder {folder, name}`. - **Variables**: `variable.bind` refuses a variable of a type the field doesn't take (a colour on `width`); scopes only filter pickers. `variable.delete` / `variable.deleteCollection` unbind the layers using them (they keep their last value) and give aliases to them their values. An @@ -330,6 +338,53 @@ tips; headless, it only sets the preference. {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"export","arguments":{"format":"png","path":"dashboard.png"}}} ``` +### Boards + +A board is a file of its own kind for thinking together: `file.newBoard {name?}` opens one (its +page is light; `inspect_document` says `kind: "board"`). Board objects are ordinary layers with a +`board` entry `get_node` shows (`{type: sticky|shape|connector|label|stamp|ink, …}`), so the usual +commands move, resize, recolour, copy, delete and undo them. On a design file the board commands +refuse; on a board `proto.*`, `dev.*`, `view.prototype` and `view.devMode` refuse. Each of these is +one undo step and returns the ids it made: + +- `board.sticky {x, y, text?, color?, author?, parent?}` → `{id, text}`: a sticky note (a square + frame whose height grows with its note; its text size shrinks as the note gets longer). `color` + is a board colour (`yellow orange red pink violet blue green gray`) or a hex colour; `author` + defaults to your name (`pref.userName`, or the one a live session gave you; `false` for none). +- `board.nextSticky {id?, side?: right|below|left|above, edit?}` → `{id, text}`: a sticky of the + same colour beside the selected (or edited) one; with `edit: true` the app goes on typing in it + (`Shift+Cmd/Ctrl+Enter` in the app does both). +- `board.shape {shape, x, y, width?, height?, text?, color?, parent?}` → `{id, text}`: a shape with centred, + wrapping text: `rectangle roundedRectangle ellipse diamond triangle speechBubble chevron`. +- `board.connector {from, to, fromSide?, toSide?, style?: straight|elbow, startCap?, endCap?, label?}` + → `{id, label?}`: `from`/`to` are layer ids or `{x, y}` page points; sides are `auto` (the side + facing the other end) or `top right bottom left`; caps are stroke caps (default an arrow at the + end). The connector re-routes itself whenever what it joins moves or resizes, also when someone + else in a live session moves it; a deleted end stays where it was as a point. `board.label {id, + text}` sets its label (empty removes it). +- `board.stamp {x, y, stamp: thumbsUp|heart|plusOne|star|question|check}` → `{id}`: a round badge + centred there, with your name on it for hover. +- `board.ink {path | points, highlighter?, color?, select? (default true)}` → `{id}`: a marker (or + wide, see-through highlighter) stroke. +- `board.setColor {ids?, color}`: stickies and shapes take it as their fill, connectors and ink as + their line. `board.setStyle {ids?, shape?, fontSize?, style?, startCap?, endCap?}`: a shape's + outline and text size, a connector's routing and arrowheads. +- Tools for `pointer`: `tool.sticky {color?}`, `tool.shape {shape?}`, `tool.connector {style?}` + (drag from one object to another), `tool.marker {color?}`, `tool.highlighter {color?}`, + `tool.eraser` (drag over strokes), `tool.stamp {stamp?}`; Text and Section are the usual tools. + +```json +{"command": "file.newBoard", "params": {"name": "Retro"}} +{"command": "board.sticky", "params": {"x": 0, "y": 0, "text": "Went well", "color": "green"}} +{"command": "board.sticky", "params": {"x": 240, "y": 0, "text": "To improve", "color": "pink"}} +{"command": "board.connector", "params": {"from": 2, "to": 4, "style": "elbow", "label": "leads to"}} +{"command": "board.stamp", "params": {"x": 200, "y": 0, "stamp": "plusOne"}} +``` + +`from` and `to` are the `id`s the two `board.sticky` calls returned (here `{"id": 2, "text": 3}` and +`{"id": 4, "text": 5}` in a new board without an author name; a sticky with its author's name takes +one more id). In a `batch`, step references (`{"from": {"$step": 1}, "to": {"$step": 2}}`, the `id`s steps 1 and 2 returned) avoid counting. + ### Sections, handoff and arrow keys `run_command` with `object.wrapInSection {ids?, name?}` (Wrap in new section: layers on the page or @@ -364,6 +419,10 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: - `view.designPrototype` (`Shift+E`), `view.devMode` (`Shift+D`) and `view.leftSidebar` (`Shift+Cmd+\`) switch the right panel's tab or hide the left sidebar in the app (no-ops headless). - `doc.nextTab` / `doc.previousTab` (`Ctrl+Tab` / `Ctrl+Shift+Tab`): the next or previous open file. +- Board tools (boards only): `tool.sticky` (`Q`), `tool.shape` (`B`), `tool.connector` (`X`), + `tool.marker` (`M`), `tool.highlighter` (`U`), `tool.eraser` (`E`), `tool.stamp` (`W`), and + `board.nextSticky` (`Shift+Cmd/Ctrl+Enter`, also while typing in a sticky). Keys no design-file + shortcut uses; on a design file they refuse. - `view.pan {dx, dy, big?}`: the arrow keys with nothing selected (`key ArrowRight` does the same) pan the view by 20 screen pixels a step, 200 with `big` (Shift). - `view.multiplayerCursors {on?}` (`Alt+Cmd+\`): show or hide others' cursors in a live session. @@ -584,7 +643,8 @@ rotation?, color?, width?, height?, responsive?}` (`None`, `iPhone 16`, `iPhone `Black`, `White`, `Pink`, `Teal`, `Blue` for the iPhone 16 (an error lists them), `""` for the default; `width` / `height` set the custom size's screen, 1–10000 points, and pick `Custom size`, which is a bare screen without a body). On a device the presented frame is sized to the device's -screen: what doesn't fit is clipped, or scrolls when the frame's `overflow` allows it, while `fixed` +screen: a frame bigger than the screen scrolls through its whole size whatever its `overflow`, content +past the frame's own edges scrolls only as its `overflow` allows (else it's clipped), and `fixed` layers stay on screen (pinned to the edges their constraints name). With `responsive: true` (saved in the file) the presented frame is instead resized to the device's screen, or without a device to the presenter's window at 100%, and its constraints and auto layout respond as when it is resized @@ -720,7 +780,9 @@ most when it can't be reached). - `instance.swap {ids?, component}` shows another component in the instances (overrides on layers with matching names stay; property values with the same names too). Nested instances can be - swapped on their own (an override of the instance they're in). + swapped on their own (an override of the instance they're in); a swapped nested instance keeps + what its outer main overrode on matching layers as its own, so later edits of those in the main + don't reach it. - `instance.swapAt {component, x, y}` swaps the instance at a page point: of the instances under it (innermost first), the first showing a variant of the same component set, else the innermost (never one inside the dropped component); only text layers keep their overrides. The Assets @@ -1302,7 +1364,9 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path strings, any other `{…}` stays as written (`unknown` lists placeholder names that aren't properties). Then `inspect.code` on an instance or the component returns the import line and the snippet instead of generated code (`connected: true`), connected instances inside a generated - layer tree are their snippet, and `inspect.componentCode` returns `connected: {importPath, name, + layer tree (also in a component's definition, in the content an instance puts in its slots, and + the components a connected template's instance swaps place) are their snippet with their import + lines once each at the top of the code, per language, and `inspect.componentCode` returns `connected: {importPath, name, template, import, code}` with the snippet as `usage`. Example: template `` with import path `@acme/ui` gives `import { Button } from "@acme/ui";` then `